Commit ca64740b authored by webchick's avatar webchick

Issue #1723892 by Berdir, Pancho, das-peter, fago: Support for revisions for...

Issue #1723892 by Berdir, Pancho, das-peter, fago: Support for revisions for entity save and delete operations.
parent bbdc5256
......@@ -7,6 +7,7 @@ Drupal 8.0, xxxx-xx-xx (development version)
* Drupal now understands the concept of a "default" revision, tracked
independently from the latest revision, allowing for the creation of
drafts while the current revision stays published.
* All entity types, not just nodes, now have support for revisions.
- Replaced the core routing system with one built on the Symfony2 framework.
- Configuration:
* Added a centralized file-based configuration system.
......
......@@ -149,6 +149,18 @@ function entity_revision_load($entity_type, $revision_id) {
return entity_get_controller($entity_type)->loadRevision($revision_id);
}
/**
* Deletes a node revision.
*
* @param string $entity_type
* The entity type to load, e.g. node or user.
* @param $revision_id
* The revision ID to delete.
*/
function entity_revision_delete($entity_type, $revision_id) {
entity_get_controller($entity_type)->deleteRevision($revision_id);
}
/**
* Loads an entity by UUID.
*
......
......@@ -121,6 +121,13 @@ public function loadRevision($revision_id) {
return FALSE;
}
/**
* Implements Drupal\Core\Entity\EntityStorageControllerInterface::deleteRevision().
*/
public function deleteRevision($revision_id) {
return NULL;
}
/**
* Implements Drupal\Core\Entity\EntityStorageControllerInterface::loadByProperties().
*/
......
......@@ -242,6 +242,23 @@ public function loadRevision($revision_id) {
return reset($queried_entities);
}
/**
* Implements Drupal\Core\Entity\EntityStorageControllerInterface::deleteRevision().
*/
public function deleteRevision($revision_id) {
if ($revision = $this->loadRevision($revision_id)) {
// Prevent deletion if this is the default revision.
if ($revision->isDefaultRevision()) {
throw new EntityStorageException('Default revision can not be deleted');
}
db_delete($this->revisionTable)
->condition($this->revisionKey, $revision->getRevisionId())
->execute();
$this->invokeHook('revision_delete', $revision);
}
}
/**
* Implements Drupal\Core\Entity\EntityStorageControllerInterface::loadByProperties().
*/
......@@ -446,6 +463,13 @@ public function delete($ids) {
db_delete($this->entityInfo['base table'])
->condition($this->idKey, $ids, 'IN')
->execute();
if ($this->revisionKey) {
db_delete($this->revisionTable)
->condition($this->idKey, $ids, 'IN')
->execute();
}
// Reset the cache as soon as the changes have been applied.
$this->resetCache($ids);
......@@ -478,13 +502,26 @@ public function save(EntityInterface $entity) {
$this->invokeHook('presave', $entity);
if (!$entity->isNew()) {
$return = drupal_write_record($this->entityInfo['base table'], $entity, $this->idKey);
if ($entity->isDefaultRevision()) {
$return = drupal_write_record($this->entityInfo['base table'], $entity, $this->idKey);
}
else {
// @todo, should a different value be returned when saving an entity
// with $isDefaultRevision = FALSE?
$return = FALSE;
}
if ($this->revisionKey) {
$this->saveRevision($entity);
}
$this->resetCache(array($entity->id()));
$this->postSave($entity, TRUE);
$this->invokeHook('update', $entity);
}
else {
$return = drupal_write_record($this->entityInfo['base table'], $entity);
if ($this->revisionKey) {
$this->saveRevision($entity);
}
// Reset general caches, but keep caches specific to certain entities.
$this->resetCache(array());
......@@ -506,6 +543,43 @@ public function save(EntityInterface $entity) {
}
}
/**
* Saves an entity revision.
*
* @param Drupal\Core\Entity\EntityInterface $entity
* The entity object.
*/
protected function saveRevision(EntityInterface $entity) {
// Convert the entity into an array as it might not have the same properties
// as the entity, it is just a raw structure.
$record = (array) $entity;
// When saving a new revision, set any existing revision ID to NULL so as to
// ensure that a new revision will actually be created, then store the old
// revision ID in a separate property for use by hook implementations.
if ($entity->isNewRevision() && $record[$this->revisionKey]) {
$record[$this->revisionKey] = NULL;
}
$this->preSaveRevision($record, $entity);
if ($entity->isNewRevision()) {
drupal_write_record($this->revisionTable, $record);
if ($entity->isDefaultRevision()) {
db_update($this->entityInfo['base table'])
->fields(array($this->revisionKey => $record[$this->revisionKey]))
->condition($this->idKey, $entity->id())
->execute();
}
$entity->setNewRevision(FALSE);
}
else {
drupal_write_record($this->revisionTable, $record, $this->revisionKey);
}
// Make sure to update the new revision key for the entity.
$entity->{$this->revisionKey} = $record[$this->revisionKey];
}
/**
* Acts on an entity before the presave hook is invoked.
*
......@@ -539,6 +613,16 @@ protected function preDelete($entities) { }
*/
protected function postDelete($entities) { }
/**
* Act on a revision before being saved.
*
* @param array $record
* The revision array.
* @param Drupal\Core\Entity\EntityInterface $entity
* The entity object.
*/
protected function preSaveRevision(array &$record, EntityInterface $entity) { }
/**
* Invokes a hook on behalf of the entity.
*
......@@ -548,7 +632,13 @@ protected function postDelete($entities) { }
* The entity object.
*/
protected function invokeHook($hook, EntityInterface $entity) {
if (!empty($this->entityInfo['fieldable']) && function_exists($function = 'field_attach_' . $hook)) {
$function = 'field_attach_' . $hook;
// @todo: field_attach_delete_revision() is named the wrong way round,
// consider renaming it.
if ($function == 'field_attach_revision_delete') {
$function = 'field_attach_delete_revision';
}
if (!empty($this->entityInfo['fieldable']) && function_exists($function)) {
$function($this->entityType, $entity);
}
// Invoke the hook.
......
......@@ -42,6 +42,13 @@ class Entity implements IteratorAggregate, EntityInterface {
*/
protected $enforceIsNew;
/**
* Boolean indicating whether a new revision should be created on save.
*
* @var bool
*/
protected $newRevision = FALSE;
/**
* Indicates whether this is the default revision.
*
......@@ -81,6 +88,14 @@ public function isNew() {
return !empty($this->enforceIsNew) || !$this->id();
}
/**
* Implements EntityInterface::isNewRevision().
*/
public function isNewRevision() {
$info = $this->entityInfo();
return $this->newRevision || (!empty($info['entity keys']['revision']) && !$this->getRevisionId());
}
/**
* Implements EntityInterface::enforceIsNew().
*/
......@@ -88,6 +103,13 @@ public function enforceIsNew($value = TRUE) {
$this->enforceIsNew = $value;
}
/**
* Implements EntityInterface::setNewRevision().
*/
public function setNewRevision($value = TRUE) {
$this->newRevision = $value;
}
/**
* Implements EntityInterface::entityType().
*/
......
......@@ -63,6 +63,26 @@ public function uuid();
*/
public function isNew();
/**
* Returns whether a new revision should be created on save.
*
* @return bool
* TRUE if a new revision should be created.
*
* @see Drupal\Core\Entity\EntityInterface::setNewRevision()
*/
public function isNewRevision();
/**
* Enforces an entity to be saved as a new revision.
*
* @param bool $value
* (optional) Whether a new revision should be saved.
*
* @see Drupal\Core\Entity\EntityInterface::isNewRevision()
*/
public function setNewRevision($value = TRUE);
/**
* Enforces an entity to be new.
*
......
......@@ -59,6 +59,16 @@ public function load(array $ids = NULL);
*/
public function loadRevision($revision_id);
/**
* Delete a specific entity revision.
*
* A revision can only be deleted if it's not the currently active one.
*
* @param int $revision_id
* The revision id.
*/
public function deleteRevision($revision_id);
/**
* Load entities by their property values.
*
......
......@@ -162,7 +162,7 @@ function book_admin_edit_submit($form, &$form_state) {
$langcode = LANGUAGE_NOT_SPECIFIED;
$node->title = $values['title'];
$node->book['link_title'] = $values['title'];
$node->revision = 1;
$node->setNewRevision();
$node->log = t('Title changed from %original to %current.', array('%original' => $node->title, '%current' => $values['title']));
$node->save();
......
......@@ -927,7 +927,7 @@ function book_page_alter(&$page) {
function book_node_presave(Node $node) {
// Always save a revision for non-administrators.
if (!empty($node->book['bid']) && !user_access('administer nodes')) {
$node->revision = 1;
$node->setNewRevision();
}
// Make sure a new node gets a new menu link.
if (empty($node->nid)) {
......
......@@ -48,13 +48,15 @@ public static function getInfo() {
protected function convertToPartialEntities($entities, $field_name) {
$partial_entities = array();
foreach ($entities as $id => $entity) {
// Re-create the entity with only the required keys, remove label as that
// is not present when using _field_create_entity_from_ids().
$partial_entities[$id] = field_test_create_entity($entity->ftid, $entity->ftvid, $entity->fttype, $entity->ftlabel);
// Remove the label and set enforceIsNew to NULL to make sure that the
// entity classes match the actual arguments.
unset($partial_entities[$id]->ftlabel);
$partial_entities[$id]->enforceIsNew(NULL);
// Re-create the entity to match what is expected
// _field_create_entity_from_ids().
$ids = (object) array(
'entity_id' => $entity->ftid,
'revision_id' => $entity->ftvid,
'bundle' => $entity->fttype,
'entity_type' => 'test_entity',
);
$partial_entities[$id] = _field_create_entity_from_ids($ids);
$partial_entities[$id]->$field_name = $entity->$field_name;
}
return $partial_entities;
......
......@@ -24,8 +24,8 @@ public static function getInfo() {
*/
function testSelectListDynamic() {
// Create an entity.
$this->entity->is_new = TRUE;
field_test_entity_save($this->entity);
$this->entity->save();
// Create a web user.
$web_user = $this->drupalCreateUser(array('access field_test content', 'administer field_test content'));
$this->drupalLogin($web_user);
......
......@@ -28,5 +28,10 @@ function options_test_allowed_values_callback($field, $instance, $entity_type, $
function options_test_dynamic_values_callback($field, $instance, $entity_type, $entity, &$cacheable) {
$cacheable = FALSE;
// We need the values of the entity as keys.
return drupal_map_assoc(array_merge(array($entity->ftlabel), array($entity->id(), $entity->getRevisionId(), $entity->bundle())));
return drupal_map_assoc(array(
$entity->ftlabel,
$entity->id(),
$entity->getRevisionId(),
$entity->bundle(),
));
}
......@@ -237,6 +237,8 @@ function field_test_create_entity($id = 1, $vid = 1, $bundle = 'test_bundle', $l
}
if (isset($vid)) {
$entity->ftvid = $vid;
// Flag to make sure that the provided vid is used for a new revision.
$entity->use_provided_revision_id = $vid;
}
$entity->fttype = $bundle;
......@@ -244,6 +246,7 @@ function field_test_create_entity($id = 1, $vid = 1, $bundle = 'test_bundle', $l
$entity->ftlabel = $label;
// Make sure the entity will saved even if a primary key is provided.
$entity->enforceIsNew();
$entity->setNewRevision();
return $entity;
}
......@@ -293,7 +296,6 @@ function field_test_entity_edit(TestEntity $entity) {
return entity_get_form($entity);
}
/**
* Form combining two separate entities.
*/
......
......@@ -35,6 +35,13 @@ class TestEntity extends Entity {
*/
public $fttype;
/**
* Label property
*
* @var string
*/
public $ftlabel;
/**
* Overrides Drupal\Core\Entity\Entity::id().
*/
......
......@@ -16,36 +16,12 @@
class TestEntityController extends DatabaseStorageController {
/**
* Overrides Drupal\Core\Entity\DatabaseStorageController::preSave().
* Overrides Drupal\Core\Entity\DatabaseStorageController::preSaveRevision().
*/
public function preSave(EntityInterface $entity) {
// Prepare for a new revision.
if (!$entity->isNew() && !empty($entity->revision)) {
$entity->old_ftvid = $entity->ftvid;
$entity->ftvid = NULL;
}
}
/**
* Overrides Drupal\Core\Entity\DatabaseStorageController::postSave().
*/
public function postSave(EntityInterface $entity, $update) {
// Only the test_entity entity type has revisions.
if ($entity->entityType() == 'test_entity') {
$update_entity = TRUE;
if (!$update || !empty($entity->revision)) {
drupal_write_record('test_entity_revision', $entity);
}
else {
drupal_write_record('test_entity_revision', $entity, 'ftvid');
$update_entity = FALSE;
}
if ($update_entity) {
db_update('test_entity')
->fields(array('ftvid' => $entity->ftvid))
->condition('ftid', $entity->ftid)
->execute();
}
public function preSaveRevision(array &$record, EntityInterface $entity) {
// Allow for predefined revision ids.
if (!empty($record['use_provided_revision_id'])) {
$record['ftvid'] = $record['use_provided_revision_id'];
}
}
......
......@@ -231,7 +231,7 @@ function file_field_insert($entity_type, $entity, $field, $instance, $langcode,
function file_field_update($entity_type, $entity, $field, $instance, $langcode, &$items) {
// On new revisions, all files are considered to be a new usage and no
// deletion of previous file usages are necessary.
if (!empty($entity->revision)) {
if (!empty($entity->original) && $entity->getRevisionId() != $entity->original->getRevisionId()) {
foreach ($items as $item) {
file_usage_add(file_load($item['fid']), 'file', $entity_type, $entity->id());
}
......
......@@ -346,7 +346,9 @@ function forum_node_presave(Node $node) {
*/
function forum_node_update(Node $node) {
if (_forum_node_check_node_type($node)) {
if (empty($node->revision) && db_query('SELECT tid FROM {forum} WHERE nid=:nid', array(':nid' => $node->nid))->fetchField()) {
// If this is not a new revision and does exist, update the forum record,
// otherwise insert a new one.
if ($node->getRevisionId() == $node->original->getRevisionId() && db_query('SELECT tid FROM {forum} WHERE nid=:nid', array(':nid' => $node->nid))->fetchField()) {
if (!empty($node->forum_tid)) {
db_update('forum')
->fields(array('tid' => $node->forum_tid))
......
......@@ -44,7 +44,7 @@ protected function prepareEntity(EntityInterface $node) {
$node->log = NULL;
}
// Always use the default revision setting.
$node->revision = in_array('revision', $node_options);
$node->setNewRevision(in_array('revision', $node_options));
node_invoke($node, 'prepare');
module_invoke_all('node_prepare', $node);
......@@ -117,8 +117,8 @@ public function form(array $form, array &$form_state, EntityInterface $node) {
'#type' => 'fieldset',
'#title' => t('Revision information'),
'#collapsible' => TRUE,
// Collapsed by default when "Create new revision" is unchecked
'#collapsed' => !$node->revision,
// Collapsed by default when "Create new revision" is unchecked.
'#collapsed' => !$node->isNewRevision(),
'#group' => 'additional_settings',
'#attributes' => array(
'class' => array('node-form-revision-information'),
......@@ -127,20 +127,20 @@ public function form(array $form, array &$form_state, EntityInterface $node) {
'js' => array(drupal_get_path('module', 'node') . '/node.js'),
),
'#weight' => 20,
'#access' => $node->revision || user_access('administer nodes'),
'#access' => $node->isNewRevision() || user_access('administer nodes'),
);
$form['revision_information']['revision'] = array(
'#type' => 'checkbox',
'#title' => t('Create new revision'),
'#default_value' => $node->revision,
'#default_value' => $node->isNewRevision(),
'#access' => user_access('administer nodes'),
);
// Check the revision log checkbox when the log textarea is filled in.
// This must not happen if "Create new revision" is enabled by default,
// since the state would auto-disable the checkbox otherwise.
if (!$node->revision) {
if (!$node->isNewRevision()) {
$form['revision_information']['revision']['#states'] = array(
'checked' => array(
'textarea[name="log"]' => array('empty' => FALSE),
......@@ -321,6 +321,11 @@ public function submit(array $form, array &$form_state) {
// Build the node object from the submitted values.
$node = parent::submit($form, $form_state);
// Save as a new revision if requested to do so.
if (!empty($form_state['values']['revision'])) {
$node->setNewRevision();
}
node_submit($node);
foreach (module_implements('node_submit') as $module) {
$function = $module . '_node_submit';
......
......@@ -9,8 +9,6 @@
use Drupal\Core\Entity\DatabaseStorageController;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageException;
use Exception;
/**
* Controller class for nodes.
......@@ -34,135 +32,6 @@ public function create(array $values) {
return $node;
}
/**
* Overrides Drupal\Core\Entity\DatabaseStorageController::delete().
*/
public function delete($ids) {
$entities = $ids ? $this->load($ids) : FALSE;
if (!$entities) {
// If no IDs or invalid IDs were passed, do nothing.
return;
}
$transaction = db_transaction();
try {
$this->preDelete($entities);
foreach ($entities as $id => $entity) {
$this->invokeHook('predelete', $entity);
}
$ids = array_keys($entities);
db_delete($this->entityInfo['base table'])
->condition($this->idKey, $ids, 'IN')
->execute();
if ($this->revisionKey) {
db_delete($this->revisionTable)
->condition($this->idKey, $ids, 'IN')
->execute();
}
// Reset the cache as soon as the changes have been applied.
$this->resetCache($ids);
$this->postDelete($entities);
foreach ($entities as $id => $entity) {
$this->invokeHook('delete', $entity);
}
// Ignore slave server temporarily.
db_ignore_slave();
}
catch (Exception $e) {
$transaction->rollback();
watchdog_exception($this->entityType, $e);
throw new EntityStorageException($e->getMessage, $e->getCode, $e);
}
}
/**
* Overrides Drupal\Core\Entity\DatabaseStorageController::save().
*/
public function save(EntityInterface $entity) {
$transaction = db_transaction();
try {
// Load the stored entity, if any.
if (!$entity->isNew() && !isset($entity->original)) {
$entity->original = entity_load_unchanged($this->entityType, $entity->id());
}
$this->preSave($entity);
$this->invokeHook('presave', $entity);
if ($entity->isNew()) {
$op = 'insert';
$return = drupal_write_record($this->entityInfo['base table'], $entity);
$entity->enforceIsNew(FALSE);
}
else {
$op = 'update';
// Update the base node table, but only if this revision is marked as
// the default.
if ($entity->isDefaultRevision()) {
$return = drupal_write_record($this->entityInfo['base table'], $entity, $this->idKey);
}
else {
// @todo, should a different value be returned when saving an entity
// with $isDefaultRevision = FALSE?
$return = FALSE;
}
}
if ($this->revisionKey) {
$this->saveRevision($entity);
}
// Reset general caches, but keep caches specific to certain entities.
$this->resetCache($op == 'update' ? array($entity->{$this->idKey}): array());
$this->postSave($entity, $op == 'update');
$this->invokeHook($op, $entity);
// Ignore slave server temporarily.
db_ignore_slave();
unset($entity->original);
return $return;
}
catch (Exception $e) {
$transaction->rollback();
watchdog_exception($this->entityType, $e);
throw new EntityStorageException($e->getMessage(), $e->getCode(), $e);
}
}
/**
* Saves a node revision.
*
* @param Drupal\Core\Entity\EntityInterface $node
* The node entity.
*/
protected function saveRevision(EntityInterface $entity) {
$record = clone $entity;
$record->uid = $entity->revision_uid;
$record->timestamp = $entity->revision_timestamp;
if (empty($entity->{$this->revisionKey}) || !empty($entity->revision)) {
drupal_write_record($this->revisionTable, $record);
// Only update the base node table if this revision is the default.
if ($entity->isDefaultRevision()) {
db_update($this->entityInfo['base table'])
->fields(array($this->revisionKey => $record->{$this->revisionKey}))
->condition($this->idKey, $entity->{$this->idKey})
->execute();
}
}
else {
drupal_write_record($this->revisionTable, $record, $this->revisionKey);
}
// Make sure to update the new revision key for the entity.
$entity->{$this->revisionKey} = $record->{$this->revisionKey};
}
/**
* Overrides Drupal\Core\Entity\DatabaseStorageController::attachLoad().
*/
......@@ -218,39 +87,6 @@ protected function invokeHook($hook, EntityInterface $node) {
}
parent::invokeHook($hook, $node);
if ($hook == 'presave') {
if ($node->isNew() || !empty($node->revision)) {
// When inserting either a new node or a new node revision, $node->log
// must be set because {node_revision}.log is a text column and therefore
// cannot have a default value. However, it might not be set at this
// point (for example, if the user submitting a node form does not have
// permission to create revisions), so we ensure that it is at least an
// empty string in that case.
// @todo: Make the {node_revision}.log column nullable so that we can
// remove this check.
if (!isset($node->log)) {
$node->log = '';
}
}
elseif (!isset($node->log) || $node->log === '') {
// If we are updating an existing node without adding a new revision, we
// need to make sure $node->log is unset whenever it is empty. As long as
// $node->log is unset, drupal_write_record() will not attempt to update
// the existing database column when re-saving the revision; therefore,
// this code allows us to avoid clobbering an existing log entry with an
// empty one.
unset($node->log);
}
// When saving a new node revision, unset any existing $node->vid so as to
// ensure that a new revision will actually be created, then store the old
// revision ID in a separate property for use by node hook implementations.
if (!$node->isNew() && !empty($node->revision) && $node->vid) {
$node->old_vid = $node->vid;
$node->vid = NULL;
}
}
}
/**
......@@ -259,14 +95,39 @@ protected function invokeHook($hook, EntityInterface $node) {
protected function preSave(EntityInterface $node) {
// Before saving the node, set changed and revision times.
$node->changed = REQUEST_TIME;
}
if ($this->revisionKey && !empty($node->revision)) {
$node->revision_timestamp = REQUEST_TIME;
if (!isset($node->revision_uid)) {
$node->revision_uid = $GLOBALS['user']->uid;
/**
* Overrides Drupal\Core\Entity\DatabaseStorageController::preSaveRevision().
*/
protected function preSaveRevision(array &$record, EntityInterface $entity) {
if ($entity->isNewRevision()) {
// When inserting either a new node or a new node revision, $node->log
// must be set because {node_revision}.log is a text column and therefore
// cannot have a default value. However, it might not be set at this