diff --git a/README.txt b/README.txt index 98b599f..ed048e4 100644 --- a/README.txt +++ b/README.txt @@ -27,7 +27,8 @@ developing, you may stop reading now. itself. * Thus the module provides API functions like entity_save(), entity_create(), - entity_delete(), entity_view() and entity_access() among others. + entity_delete(), entity_revision_delete(), entity_view() and entity_access() + among others. entity_load(), entity_label() and entity_uri() are already provided by Drupal core. @@ -48,17 +49,16 @@ developing, you may stop reading now. "Entity" class is provided. In particular, it is useful to extend this class in order to easily customize the entity type, e.g. saving. - * The controller supports fieldable entities, however it does not yet support - revisions. There is also a controller which supports implementing exportable - entities. + * The controller supports fieldable entities and revisions. There is also a + controller which supports implementing exportable entities. * The Entity CRUD API helps with providing additional module integration too, e.g. exportable entities are automatically integrated with the Features module. These module integrations are implemented in separate controller classes, which may be overridden and deactivated on their own. - * There is also an optional ui controller class, which assits with providing an - administrative UI for managing entities of a certain type. + * There is also an optional ui controller class, which assists with providing + an administrative UI for managing entities of a certain type. * For more details check out the documentation in the drupal.org handbook http://drupal.org/node/878804 as well as the API documentation, i.e. diff --git a/entity.api.php b/entity.api.php index 1dfb0cf..b371791 100644 --- a/entity.api.php +++ b/entity.api.php @@ -74,6 +74,11 @@ * - status: (optional) The name of the entity property used by the entity * CRUD API to save the exportable entity status using defined bit flags. * Defaults to 'status'. See entity_has_status(). + * - default revision: (optional) The name of the entity property used by + * the entity CRUD API to determine if a newly-created revision should be + * set as the default revision. Defaults to 'default_revision'. + * Note that on entity insert the created revision will be always default + * regardless of the value of this entity property. * - export: (optional) An array of information used for exporting. For ctools * exportables compatibility any export-keys supported by ctools may be added * to this array too. @@ -198,6 +203,8 @@ function entity_crud_hook_entity_info() { * this type. * - deletion callback: (optional) A callback that permanently deletes an * entity of this type. + * - revision deletion callback: (optional) A callback that deletes a revision + * of the entity. * - view callback: (optional) A callback to render a list of entities. * See entity_metadata_view_node() as example. * - form callback: (optional) A callback that returns a fully built edit form diff --git a/entity.module b/entity.module index 3d559ac..345ab24 100644 --- a/entity.module +++ b/entity.module @@ -54,7 +54,11 @@ define('ENTITY_FIXED', 0x04 | 0x02); * @param $entity_type * The type of the entity. * @param $op - * One of 'create', 'view', 'save', 'delete', 'access' or 'form'. + * One of 'create', 'view', 'save', 'delete', 'revision delete', 'access' or + * 'form'. + * + * @return boolean + * Whether the entity type supports the given operation. */ function entity_type_supports($entity_type, $op) { $info = entity_get_info($entity_type); @@ -62,6 +66,7 @@ function entity_type_supports($entity_type, $op) { 'view' => 'view callback', 'create' => 'creation callback', 'delete' => 'deletion callback', + 'revision delete' => 'revision deletion callback', 'save' => 'save callback', 'access' => 'access callback', 'form' => 'form callback' @@ -69,12 +74,16 @@ function entity_type_supports($entity_type, $op) { if (isset($info[$keys[$op]])) { return TRUE; } - if ($op != 'access' && in_array('EntityAPIControllerInterface', class_implements($info['controller class']))) { - return TRUE; + if ($op == 'revision delete') { + return in_array('EntityAPIControllerInterface', class_implements($info['controller class'])); } - if ($op == 'form' && entity_ui_controller($entity_type)) { - return TRUE; + if ($op == 'form') { + return (bool) entity_ui_controller($entity_type); } + if ($op != 'access') { + return in_array('EntityAPIControllerInterface', class_implements($info['controller class'])); + } + return FALSE; } /** @@ -96,6 +105,7 @@ function entity_type_supports($entity_type, $op) { * The ID of the entity to load, passed by the menu URL. * @param $entity_type * The type of the entity to load. + * * @return * A fully loaded entity object, or FALSE in case of error. */ @@ -234,6 +244,90 @@ function entity_delete_multiple($entity_type, $ids) { } /** + * Loads an entity revision. + * + * @param $entity_type + * The type of the entity. + * @param $revision_id + * The id of the revision to load. + * + * @return + * The entity object, or FALSE if there is no entity with the given revision + * id. + */ +function entity_revision_load($entity_type, $revision_id) { + $info = entity_get_info($entity_type); + if (!empty($info['entity keys']['revision'])) { + $entity_revisions = entity_load($entity_type, FALSE, array($info['entity keys']['revision'] => $revision_id)); + return reset($entity_revisions); + } + return FALSE; +} + +/** + * Deletes an entity revision. + * + * @param $entity_type + * The type of the entity. + * @param $revision_id + * The revision ID to delete. + * + * @return + * TRUE if the entity revision could be deleted, FALSE otherwise. + */ +function entity_revision_delete($entity_type, $revision_id) { + $info = entity_get_info($entity_type); + if (isset($info['revision deletion callback'])) { + return $info['revision deletion callback']($revision_id, $entity_type); + } + elseif (in_array('EntityAPIControllerRevisionableInterface', class_implements($info['controller class']))) { + return entity_get_controller($entity_type)->deleteRevision($revision_id); + } + return FALSE; +} + +/** + * Checks whether the given entity is the default revision. + * + * @param $entity_type + * The type of the entity. + * @param $entity + * The entity object to check. + * + * @return boolean + * TRUE if the entity is the default revision or the entity is not + * revisionable, FALSE otherwise. + * + * @see entity_revision_set_default() + */ +function entity_revision_is_default($entity_type, $entity) { + $info = entity_get_info($entity_type); + if (!empty($info['entity keys']['revision'])) { + $key = !empty($info['entity keys']['default revision']) ? $info['entity keys']['default revision'] : 'default_revision'; + return !empty($entity->$key); + } + return TRUE; +} + +/** + * Sets a given entity revision as default revision. + * + * @param $entity_type + * The type of the entity. + * @param $entity + * The entity revision to update. + * + * @see entity_revision_is_default() + */ +function entity_revision_set_default($entity_type, $entity) { + $info = entity_get_info($entity_type); + if (!empty($info['entity keys']['revision'])) { + $key = !empty($info['entity keys']['default revision']) ? $info['entity keys']['default revision'] : 'default_revision'; + $entity->$key = TRUE; + } +} + +/** * Create a new entity object. * * @param $entity_type @@ -1261,6 +1355,7 @@ function _entity_info_add_metadata(&$entity_info) { $entity_info['node']['creation callback'] = 'entity_metadata_create_node'; $entity_info['node']['save callback'] = 'node_save'; $entity_info['node']['deletion callback'] = 'node_delete'; + $entity_info['node']['revision deletion callback'] = 'node_revision_delete'; $entity_info['user']['creation callback'] = 'entity_metadata_create_object'; $entity_info['user']['save callback'] = 'entity_metadata_user_save'; $entity_info['user']['deletion callback'] = 'user_delete'; diff --git a/entity.test b/entity.test index aaebc61..746713e 100644 --- a/entity.test +++ b/entity.test @@ -96,6 +96,113 @@ class EntityAPITestCase extends EntityWebTestCase { } /** + * Tests CRUD for entities supporting revisions. + */ + function testCRUDRevisisions() { + module_enable(array('entity_feature')); + + // Add text field to entity. + $field_info = array( + 'field_name' => 'field_text', + 'type' => 'text', + 'entity_types' => array('entity_test2'), + ); + field_create_field($field_info); + + $instance = array( + 'label' => 'Text Field', + 'field_name' => 'field_text', + 'entity_type' => 'entity_test2', + 'bundle' => 'entity_test2', + 'settings' => array(), + 'required' => FALSE, + ); + field_create_instance($instance); + + // Create a test entity. + $entity_first_revision = entity_create('entity_test2', array('title' => 'first revision', 'name' => 'main', 'uid' => 1)); + $entity_first_revision->field_text[LANGUAGE_NONE][0]['value'] = 'first revision text'; + entity_save('entity_test2', $entity_first_revision); + + $entities = array_values(entity_load('entity_test2', FALSE)); + $this->assertEqual(count($entities), 1, 'Entity created.'); + $this->assertTrue($entities[0]->default_revision, 'Initial entity revision is marked as default revision.'); + + // Saving the entity in revision mode should create a new revision. + $entity_second_revision = clone $entity_first_revision; + $entity_second_revision->title = 'second revision'; + $entity_second_revision->is_new_revision = TRUE; + $entity_second_revision->default_revision = TRUE; + $entity_second_revision->field_text[LANGUAGE_NONE][0]['value'] = 'second revision text'; + + entity_save('entity_test2', $entity_second_revision); + $this->assertNotEqual($entity_second_revision->revision_id, $entity_first_revision->revision_id, 'Saving an entity in new revision mode creates a revision.'); + $this->assertTrue($entity_second_revision->default_revision, 'New entity revision is marked as default revision.'); + + // Check the saved entity. + $entity = current(entity_load('entity_test2', array($entity_first_revision->pid), array(), TRUE)); + $this->assertNotEqual($entity->title, $entity_first_revision->title, 'Default revision was changed.'); + + // Create third revision that is not default. + $entity_third_revision = clone $entity_first_revision; + $entity_third_revision->title = 'third revision'; + $entity_third_revision->is_new_revision = TRUE; + $entity_third_revision->default_revision = FALSE; + $entity_third_revision->field_text[LANGUAGE_NONE][0]['value'] = 'third revision text'; + entity_save('entity_test2', $entity_third_revision); + $this->assertNotEqual($entity_second_revision->revision_id, $entity_third_revision->revision_id, 'Saving an entity in revision mode creates a revision.'); + $this->assertFalse($entity_third_revision->default_revision, 'Entity revision is not marked as default revision.'); + + $entity = current(entity_load('entity_test2', array($entity_first_revision->pid), array(), TRUE)); + $this->assertEqual($entity->title, $entity_second_revision->title, 'Default revision was not changed.'); + $this->assertEqual($entity->field_text[LANGUAGE_NONE][0]['value'], $entity_second_revision->field_text[LANGUAGE_NONE][0]['value'], 'Default revision text field was not changed.'); + + // Load not default revision. + $revision = entity_revision_load('entity_test2', $entity_third_revision->revision_id); + $this->assertEqual($revision->revision_id, $entity_third_revision->revision_id, 'Revision successfully loaded.'); + $this->assertFalse($revision->default_revision, 'Entity revision is not marked as default revision after loading.'); + + // Save not default revision. + $entity_third_revision->title = 'third revision updated'; + $entity_third_revision->field_text[LANGUAGE_NONE][0]['value'] = 'third revision text updated'; + entity_save('entity_test2', $entity_third_revision); + + // Ensure that not default revision has been changed. + $entity = entity_revision_load('entity_test2', $entity_third_revision->revision_id); + $this->assertEqual($entity->title, 'third revision updated', 'Not default revision was updated successfully.'); + $this->assertEqual($entity->field_text[LANGUAGE_NONE][0]['value'], 'third revision text updated', 'Not default revision field was updated successfully.'); + + // Ensure that default revision has not been changed. + $entity = current(entity_load('entity_test2', array($entity_first_revision->pid), array(), TRUE)); + $this->assertEqual($entity->title, $entity_second_revision->title, 'Default revision was not changed.'); + + // Try to delete default revision. + $result = entity_revision_delete('entity_test2', $entity_second_revision->revision_id); + $this->assertFalse($result, 'Default revision cannot be deleted.'); + + // Make sure default revision is still set after trying to delete it. + $entity = current(entity_load('entity_test2', array($entity_first_revision->pid), array(), TRUE)); + $this->assertEqual($entity->revision_id, $entity_second_revision->revision_id, 'Second revision is still default.'); + + // Delete first revision. + $result = entity_revision_delete('entity_test2', $entity_first_revision->revision_id); + $this->assertTrue($result, 'Not default revision deleted.'); + + $entity = entity_revision_load('entity_test2', $entity_first_revision->revision_id); + $this->assertFalse($entity, 'First revision deleted.'); + + // Delete the entity and make sure third revision has been deleted as well. + entity_delete('entity_test2', $entity_second_revision->pid); + $entity_info = entity_get_info('entity_test2'); + $result = db_select($entity_info['revision table']) + ->condition('revision_id', $entity_third_revision->revision_id) + ->countQuery() + ->execute() + ->fetchField(); + $this->assertEqual($result, 0, 'Entity deleted with its all revisions.'); + } + + /** * Tests CRUD API functions: entity_(create|delete|save) */ function testCRUDAPIfunctions() { diff --git a/includes/entity.controller.inc b/includes/entity.controller.inc index 76569c1..2f87010 100644 --- a/includes/entity.controller.inc +++ b/includes/entity.controller.inc @@ -121,12 +121,33 @@ interface EntityAPIControllerInterface extends DrupalEntityControllerInterface { } /** + * Interface for EntityControllers of entities that support revisions. + */ +interface EntityAPIControllerRevisionableInterface extends EntityAPIControllerInterface { + + /** + * Delete an entity revision. + * + * Note that the default revision of an entity cannot be deleted. + * + * @param $revision_id + * The ID of the revision to delete. + * + * @return boolean + * TRUE if the entity revision could be deleted, FALSE otherwise. + */ + public function deleteRevision($revision_id); + +} + +/** * A controller implementing EntityAPIControllerInterface for the database. */ -class EntityAPIController extends DrupalDefaultEntityController implements EntityAPIControllerInterface { +class EntityAPIController extends DrupalDefaultEntityController implements EntityAPIControllerRevisionableInterface { protected $cacheComplete = FALSE; protected $bundleKey; + protected $defaultRevisionKey; /** * Overridden. @@ -139,6 +160,20 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit $info = entity_get_info($this->entityInfo['bundle of']); $this->bundleKey = $info['bundle keys']['bundle']; } + $this->defaultRevisionKey = !empty($this->entityInfo['entity keys']['default revision']) ? $this->entityInfo['entity keys']['default revision'] : 'default_revision'; + } + + /** + * Overrides DrupalDefaultEntityController::buildQuery(). + */ + protected function buildQuery($ids, $conditions = array(), $revision_id = FALSE) { + $query = parent::buildQuery($ids, $conditions, $revision_id); + if ($this->revisionKey) { + // Compare revision id of the base and revision table, if equal then this + // is the default revision. + $query->addExpression('base.' . $this->revisionKey . ' = revision.' . $this->revisionKey, $this->defaultRevisionKey); + } + return $query; } /** @@ -275,6 +310,9 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit return $entities; } + /** + * Overrides DrupalDefaultEntityController::resetCache(). + */ public function resetCache(array $ids = NULL) { $this->cacheComplete = FALSE; parent::resetCache($ids); @@ -288,7 +326,12 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit * Implements EntityAPIControllerInterface. */ public function invoke($hook, $entity) { - if (!empty($this->entityInfo['fieldable']) && function_exists($function = 'field_attach_' . $hook)) { + // entity_revision_delete() invokes hook_entity_revision_delete() and + // hook_field_attach_delete_revision() just as node module does. So we need + // to adjust the name of our revision deletion field attach hook in order to + // stick to this pattern. + $field_attach_hook = ($hook == 'revision_delete' ? 'delete_revision' : $hook); + if (!empty($this->entityInfo['fieldable']) && function_exists($function = 'field_attach_' . $field_attach_hook)) { $function($this->entityType, $entity); } @@ -341,6 +384,12 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit db_delete($this->entityInfo['base table']) ->condition($this->idKey, $ids, 'IN') ->execute(); + + if (isset($this->revisionTable)) { + db_delete($this->revisionTable) + ->condition($this->idKey, $ids, 'IN') + ->execute(); + } // Reset the cache as soon as the changes have been applied. $this->resetCache($ids); @@ -360,6 +409,26 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit } /** + * Implements EntityAPIControllerRevisionableInterface::deleteRevision(). + */ + public function deleteRevision($revision_id) { + if ($entity_revision = entity_revision_load($this->entityType, $revision_id)) { + // Prevent deleting the default revision. + if (entity_revision_is_default($this->entityType, $entity_revision)) { + return FALSE; + } + + db_delete($this->revisionTable) + ->condition($this->revisionKey, $revision_id) + ->execute(); + + $this->invoke('revision_delete', $entity_revision); + return TRUE; + } + return FALSE; + } + + /** * Implements EntityAPIControllerInterface. * * @param $transaction @@ -375,21 +444,40 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit // entity using the id key if it is available. $entity->original = entity_load_unchanged($this->entityType, $entity->{$this->idKey}); } - + $entity->is_new = !empty($entity->is_new) || empty($entity->{$this->idKey}); $this->invoke('presave', $entity); - if (!empty($entity->{$this->idKey}) && empty($entity->is_new)) { - $return = drupal_write_record($this->entityInfo['base table'], $entity, $this->idKey); - $this->resetCache(array($entity->{$this->idKey})); - $this->invoke('update', $entity); - } - else { + if ($entity->is_new) { $return = drupal_write_record($this->entityInfo['base table'], $entity); + if ($this->revisionKey) { + $this->saveRevision($entity); + } $this->invoke('insert', $entity); } + else { + // Update the base table if the entity doesn't have revisions or + // we are updating the default revision. + if (!$this->revisionKey || !empty($entity->{$this->defaultRevisionKey})) { + $return = drupal_write_record($this->entityInfo['base table'], $entity, $this->idKey); + } + if ($this->revisionKey) { + $return = $this->saveRevision($entity); + } + $this->resetCache(array($entity->{$this->idKey})); + $this->invoke('update', $entity); + + // Field API always saves as default revision, so if the revision saved + // is not default we have to restore the field values of the default + // revision now by invoking field_attach_update() once again. + if ($this->revisionKey && !$entity->{$this->defaultRevisionKey} && !empty($this->entityInfo['fieldable'])) { + field_attach_update($this->entityType, $entity->original); + } + } + // Ignore slave server temporarily. db_ignore_slave(); unset($entity->is_new); + unset($entity->is_new_revision); unset($entity->original); return $return; @@ -402,6 +490,54 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit } /** + * Saves an entity revision. + * + * @param Entity $entity + * Entity revision to save. + */ + protected function saveRevision($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; + // File fields assumes we are using $entity->revision instead of + // $entity->is_new_revision, so we also support it and make sure it's set to + // the same value. + $entity->is_new_revision = !empty($entity->is_new_revision) || !empty($entity->revision) || $entity->is_new; + $entity->revision = &$entity->is_new_revision; + $entity->{$this->defaultRevisionKey} = !empty($entity->{$this->defaultRevisionKey}) || $entity->is_new; + + + + // When saving a new revision, set any existing revision ID to NULL so as to + // ensure that a new revision will actually be created. + if ($entity->is_new_revision && isset($record[$this->revisionKey])) { + $record[$this->revisionKey] = NULL; + } + + if ($entity->is_new_revision) { + drupal_write_record($this->revisionTable, $record); + $update_default_revision = $entity->{$this->defaultRevisionKey}; + } + else { + drupal_write_record($this->revisionTable, $record, $this->revisionKey); + // @todo: Fix original entity to be of the same revision and check whether + // the default revision key has been set. + $update_default_revision = $entity->{$this->defaultRevisionKey} && $entity->{$this->revisionKey} != $entity->original->{$this->revisionKey}; + } + // Make sure to update the new revision key for the entity. + $entity->{$this->revisionKey} = $record[$this->revisionKey]; + + // Mark this revision as the default one. + if ($update_default_revision) { + db_update($this->entityInfo['base table']) + ->fields(array($this->revisionKey => $record[$this->revisionKey])) + ->condition($this->idKey, $entity->{$this->idKey}) + ->execute(); + } + return $entity->is_new_revision ? SAVED_NEW : SAVED_UPDATED; + } + + /** * Implements EntityAPIControllerInterface. */ public function create(array $values = array()) { diff --git a/includes/entity.inc b/includes/entity.inc index 90a53c4..9bb9e3b 100644 --- a/includes/entity.inc +++ b/includes/entity.inc @@ -268,6 +268,21 @@ class Entity { } /** + * Checks whether the entity is the default revision. + * + * @return Boolean + * + * @see entity_revision_is_default() + */ + public function isDefaultRevision() { + if (!empty($this->entityInfo['entity keys']['revision'])) { + $key = !empty($this->entityInfo['entity keys']['default revision']) ? $this->entityInfo['entity keys']['default revision'] : 'default_revision'; + return !empty($this->$key); + } + return TRUE; + } + + /** * Magic method to only serialize what's necessary. */ public function __sleep() { diff --git a/tests/entity_test.install b/tests/entity_test.install index dce2161..42ba4b6 100644 --- a/tests/entity_test.install +++ b/tests/entity_test.install @@ -121,6 +121,44 @@ function entity_test_schema() { 'name' => array('name'), ), ); + + // Add schema for the revision-test-entity. + $schema['entity_test2'] = $schema['entity_test']; + $schema['entity_test2']['fields']['revision_id'] = array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + 'default' => NULL, + 'description' => 'The ID of the entity\'s default revision.', + ); + $schema['entity_test2']['fields']['title'] = array( + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ); + + $schema['entity_test2_revision'] = $schema['entity_test']; + $schema['entity_test2_revision']['fields']['revision_id'] = array( + 'type' => 'serial', + 'not null' => TRUE, + 'description' => 'Primary Key: Unique revision ID.', + ); + $schema['entity_test2_revision']['fields']['pid'] = array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + 'default' => NULL, + 'description' => 'The ID of the attached entity.', + ); + $schema['entity_test2_revision']['fields']['title'] = array( + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ); + $schema['entity_test2_revision']['primary key'] = array('revision_id'); + return $schema; } diff --git a/tests/entity_test.module b/tests/entity_test.module index 4076a97..8c82afe 100644 --- a/tests/entity_test.module +++ b/tests/entity_test.module @@ -45,6 +45,26 @@ function entity_test_entity_info() { ), 'module' => 'entity_test', ), + + 'entity_test2' => array( + 'label' => t('Test Entity (revision support)'), + 'entity class' => 'EntityClassRevision', + 'controller class' => 'EntityAPIController', + 'base table' => 'entity_test2', + 'revision table' => 'entity_test2_revision', + 'fieldable' => TRUE, + 'entity keys' => array( + 'id' => 'pid', + 'revision' => 'revision_id', + ), + // Make use of the class label() and uri() implementation by default. + 'label callback' => 'entity_class_label', + 'uri callback' => 'entity_class_uri', + 'bundles' => array(), + 'bundle keys' => array( + 'bundle' => 'name', + ), + ), ); // Add bundle info but bypass entity_load() as we cannot use it here. @@ -141,6 +161,16 @@ class EntityClass extends Entity { } } +/** + * Main class for test entities (with revision support). + */ +class EntityClassRevision extends EntityClass { + + public function __construct(array $values = array(), $entityType = NULL) { + Entity::__construct($values, 'entity_test2'); + } + +} /** * @@ -155,7 +185,9 @@ class EntityClass extends Entity { * Implements hook_entity_insert(). */ function entity_test_entity_insert($entity, $entity_type) { - $_SESSION['entity_hook_test']['entity_insert'][] = entity_id($entity_type, $entity); + if ($entity_type == 'entity_test_type') { + $_SESSION['entity_hook_test']['entity_insert'][] = entity_id($entity_type, $entity); + } } /** @@ -169,7 +201,9 @@ function entity_test_entity_update($entity, $entity_type) { * Implements hook_entity_delete(). */ function entity_test_entity_delete($entity, $entity_type) { - $_SESSION['entity_hook_test']['entity_delete'][] = entity_id($entity_type, $entity); + if ($entity_type == 'entity_test_type') { + $_SESSION['entity_hook_test']['entity_delete'][] = entity_id($entity_type, $entity); + } } /**