diff --git a/README.txt b/README.txt index d3c524b..824ab11 100644 --- a/README.txt +++ b/README.txt @@ -26,7 +26,8 @@ you may stop reading now. module, the module integration has to be provided the contrib module 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. @@ -47,17 +48,16 @@ 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 integrate 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. @@ -152,5 +152,3 @@ you may stop reading now. data, you can do so via: $wrapper->body->value->raw(); - - \ No newline at end of file diff --git a/entity.module b/entity.module index 08e4662..3461e85 100644 --- a/entity.module +++ b/entity.module @@ -62,6 +62,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' @@ -96,6 +97,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. */ @@ -215,6 +217,7 @@ function entity_delete($entity_type, $id) { * @param $ids * An array of entity ids of the entities to delete. In case the entity makes * use of a name key, both the names or numeric ids may be passed. + * * @return * FALSE if the given entity type isn't compatible to the CRUD API. */ @@ -234,6 +237,27 @@ function entity_delete_multiple($entity_type, $ids) { } /** + * Deletes an entity revision. + * + * @param $entity_type + * The type of the entity. + * @param $revision_id + * The revision ID to delete. + * + * @return + * FALSE, if there were no information how to delete the entity revision. + */ +function entity_revision_delete($entity_type, $revision_id) { + $info = entity_get_info($entity_type); + if (in_array('EntityAPIControllerInterface', class_implements($info['controller class']))) { + return entity_get_controller($entity_type)->revisionDelete($revision_id); + } + else { + return FALSE; + } +} + +/** * Create a new entity object. * * @param $entity_type @@ -241,6 +265,7 @@ function entity_delete_multiple($entity_type, $ids) { * @param $values * An array of values to set, keyed by property name. If the entity type has * bundles the bundle key has to be specified. + * * @return * A new instance of the entity type or FALSE if there is no information for * the given entity type. @@ -270,6 +295,7 @@ function entity_create($entity_type, array $values) { * The entity to export. * @param $prefix * An optional prefix for each line. + * * @return * The exported entity as serialized string. The format is determined by the * respective entity controller, e.g. it is JSON for the EntityAPIController. @@ -296,6 +322,7 @@ function entity_export($entity_type, $entity, $prefix = '') { * @param string $export * The string containing the serialized entity as produced by * entity_export(). + * * @return * The imported entity object not yet saved. */ @@ -324,6 +351,7 @@ function entity_import($entity_type, $export) { * @param $langcode * (optional) A language code to use for rendering. Defaults to the global * content language of the current request. + * * @return * The renderable array. */ @@ -385,6 +413,7 @@ function entity_id($entity_type, $entity) { * of the entity, as returned by entity_uri(). * This parameter is only supported for entities which controller is a * EntityAPIControllerInterface. + * * @return * The renderable array, keyed by the entity type and by entity identifiers, * for which the entity name is used if existing - see entity_id(). If there @@ -441,6 +470,7 @@ function entity_access($op, $entity_type, $entity = NULL, $account = NULL) { * The type of the entity. * @param $entity * The entity to show the edit form for. + * * @return * The renderable array of the form. If there is no entity form or missing * metadata, FALSE is returned. @@ -1137,6 +1167,7 @@ function entity_views_api() { * info before it is utilized by the wrapper. * - property defaults: (optional) An array of defaults for the info of * each property of the wrapped data item. + * * @return EntityMetadataWrapper * Dependend on the passed data the right wrapper is returned. */ diff --git a/entity.test b/entity.test index eb885c2..a13a1b0 100644 --- a/entity.test +++ b/entity.test @@ -96,6 +96,76 @@ class EntityAPITestCase extends EntityWebTestCase { } /** + * Tests CRUD for entities support revisions. + */ + function testCRUDRevisisions() { + module_enable(array('entity_feature')); + + $user1 = $this->drupalCreateUser(); + $user2 = $this->drupalCreateUser(); + + // Create a test entity. + $entity_initial = entity_create('entity_test_revision', array('name' => 'test', 'uid' => $user1->uid)); + $entity_initial->save(); + + $entities = array_values(entity_load('entity_test_revision', FALSE)); + $this->assertEqual(count($entities), 1, 'Entity created.'); + + // Saving the entity in revision mode should create a new revision. + $entity_bis = clone $entity_initial; + $entity_bis->uid = $user2->uid; + $entity_bis->is_new_revision = TRUE; + $entity_bis->save(); + $this->assertNotEqual($entity_bis->rid, $entity_initial->rid, 'Saving an entity in revision mode creates a revision.'); + + // Check the saved entity. + $entities = entity_load('entity_test_revision', array($entity_initial->pid)); + $entity = reset($entities); + $this->assertEqual($entity->uid, $user2->uid, 'Modifications to the entity were saved.'); + + // Check the initial revision. + $entities = entity_load('entity_test_revision', array($entity_initial->pid), array('rid' => $entity_initial->rid)); + $entity = reset($entities); + $this->assertEqual($entity->uid, $user1->uid, 'Modifications to the entity have not affected the initial revision.'); + + // Saving the entity normally should not create a new revision. + $entity_ter = clone $entity_bis; + $entity_ter->uid = $user1->uid; + $entity_ter->save(); + $this->assertEqual($entity_ter->rid, $entity_bis->rid, 'Saving an entity does not create a revision.'); + + // Check the saved entity. + $entities = entity_load('entity_test_revision', array($entity_initial->pid)); + $entity = reset($entities); + $this->assertEqual($entity->uid, $user1->uid, 'Modifications to the entity were saved.'); + + $entity_controller = entity_get_controller('entity_test_revision'); + $this->assertTrue($entity_controller->isCurrentRevision($entity), 'Revision is current.'); + + // Update revision. + $entities = entity_load('entity_test_revision', array($entity_initial->pid), array('rid' => $entity_initial->rid)); + $entity = reset($entities); + $entity->uid = $user2->uid; + $entity->save(); + + $entities = entity_load('entity_test_revision', array($entity_initial->pid), array('rid' => $entity_initial->rid)); + $entity = reset($entities); + $this->assertEqual($entity->uid, $user2->uid, 'Modifications to the entity revision were saved.'); + + $this->assertFalse($entity_controller->isCurrentRevision($entity), 'Revision is not current.'); + + // Delete entity revision. + entity_revision_delete('entity_test_revision', $entity_initial->rid); + $entities = entity_load('entity_test_revision', array($entity_initial->pid), array('rid' => $entity_initial->rid)); + $this->assertEqual(count($entities), 0, 'Entity revision successfully deleted.'); + + // Delete the entity. + $entity_initial->delete(); + $entities = array_values(entity_load('entity_test_revision', FALSE)); + $this->assertEqual(count($entities), 0, 'Entity successfully deleted.'); + } + + /** * Tests CRUD API functions: entity_(create|delete|save) */ function testCRUDAPIfunctions() { diff --git a/includes/entity.controller.inc b/includes/entity.controller.inc index 242a3db..08be9f9 100644 --- a/includes/entity.controller.inc +++ b/includes/entity.controller.inc @@ -286,7 +286,11 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit * Implements EntityAPIControllerInterface. */ public function invoke($hook, $entity) { - if (!empty($this->entityInfo['fieldable']) && function_exists($function = 'field_attach_' . $hook)) { + // In node_revision_delete() core invokes hook_node_revision_delete() and + // hook_field_attach_delete_revision(), 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); } @@ -319,6 +323,8 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit /** * Implements EntityAPIControllerInterface. * + * Deletes an entity and all its revisions. + * * @param $transaction * Optionally a DatabaseTransaction object to use. Allows overrides to pass * in their transaction object. @@ -340,6 +346,12 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit db_delete($this->entityInfo['base table']) ->condition($this->idKey, $ids, 'IN') ->execute(); + + if (isset($this->entityInfo['revision table'])) { + db_delete($this->entityInfo['revision table']) + ->condition($this->idKey, $ids, 'IN') + ->execute(); + } // Reset the cache as soon as the changes have been applied. $this->resetCache($ids); @@ -359,6 +371,34 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit } /** + * Deletes an entity revision. + * + * @param $revision_id + * Revision ID. + * + * @return boolean + * TRUE if the entity revision could be deleted, FALSE otherwise. + */ + public function revisionDelete($revision_id) { + if ($entity_revisions = $this->load(FALSE, array($this->revisionKey => $revision_id))) { + $entity_revision = reset($entity_revisions); + // Prevent deleting the current revision. + $entities = $this->load(array($entity_revision->{$this->idKey})); + $entity = reset($entities); + if ($entity->{$this->revisionKey} == $revision_id) { + return FALSE; + } + + db_delete($this->entityInfo['revision table']) + ->condition($this->revisionKey, $revision_id) + ->execute(); + + $this->invoke('revision_delete', $entity_revision); + } + return FALSE; + } + + /** * Implements EntityAPIControllerInterface. * * @param $transaction @@ -377,19 +417,53 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit $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); + // When saving a new revision, unset any existing revision ID 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 (!empty($this->revisionKey) && empty($entity->is_new) && !empty($entity->is_new_revision) && !empty($entity->{$this->revisionKey})) { + unset($entity->{$this->revisionKey}); } - else { + + $return = FALSE; + if (empty($entity->{$this->idKey}) || !empty($entity->is_new)) { + // For new entities, create the row in the base table, then save the + // revision. $return = drupal_write_record($this->entityInfo['base table'], $entity); + if (!empty($this->revisionKey)) { + $this->saveRevision($entity); + $update_base_table = (isset($entity->set_active_revision)) ? $entity->set_active_revision : TRUE; + } $this->invoke('insert', $entity); } + else { + // Only update the base table if the entity doesn't have revisions or + // we are updating the active revision. + if (empty($this->revisionKey) || !isset($entity->{$this->revisionKey}) || $entity->{$this->revisionKey} == $entity->original->{$this->revisionKey}) { + $return = drupal_write_record($this->entityInfo['base table'], $entity, $this->idKey); + } + + if (!empty($this->revisionKey)) { + $update_base_table_default = $this->saveRevision($entity); + $update_base_table = (isset($entity->set_active_revision)) ? $entity->set_active_revision : $update_base_table_default; + } + + $this->resetCache(array($entity->{$this->idKey})); + $this->invoke('update', $entity); + } + + if (!empty($update_base_table)) { + // Go back to the base table and update the pointer to the revision ID. + db_update($this->entityInfo['base table']) + ->fields(array($this->revisionKey => $entity->{$this->revisionKey})) + ->condition($this->idKey, $entity->{$this->idKey}) + ->execute(); + } + // Ignore slave server temporarily. db_ignore_slave(); unset($entity->is_new); unset($entity->original); + unset($entity->is_new_revision); return $return; } @@ -400,6 +474,28 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit } } + protected function saveRevision($entity) { + if (!empty($entity->is_new_revision) || (isset($entity->is_new) && $entity->is_new)) { + drupal_write_record($this->entityInfo['revision table'], $entity); + return TRUE; + } + else { + drupal_write_record($this->entityInfo['revision table'], $entity, $this->revisionKey); + } + } + + /** + * Check whether this is the current / active revision of the entity. + */ + public function isCurrentRevision($entity) { + if (!isset($this->revisionKey) || !isset($entity->{$this->revisionKey})) { + return; + } + $query = $this->buildQuery(array($entity->{$this->idKey}), array($this->revisionKey => $entity->{$this->revisionKey})); + $result = $query->execute()->fetchAll(); + return !empty($result); + } + /** * Implements EntityAPIControllerInterface. */ diff --git a/tests/entity_feature.module b/tests/entity_feature.module index c2c9fbf..a3c2781 100644 --- a/tests/entity_feature.module +++ b/tests/entity_feature.module @@ -30,3 +30,29 @@ function entity_feature_default_entity_test_type() { return $types; } + +/** + * Implements hook_default_entity_test_revision_type(). + */ +function entity_feature_default_entity_test_revision_type() { + $types['main'] = entity_create('entity_test_revision_type', array( + 'name' => 'main', + 'label' => t('Main test type'), + 'weight' => 0, + 'locked' => TRUE, + )); + + // Types used during CRUD testing. + $types['test'] = entity_create('entity_test_revision_type', array( + 'name' => 'test', + 'label' => 'label', + 'weight' => 0, + )); + $types['test2'] = entity_create('entity_test_revision_type', array( + 'name' => 'test2', + 'label' => 'label2', + 'weight' => 2, + )); + + return $types; +} diff --git a/tests/entity_test.install b/tests/entity_test.install index dce2161..d935c18 100644 --- a/tests/entity_test.install +++ b/tests/entity_test.install @@ -121,6 +121,35 @@ function entity_test_schema() { 'name' => array('name'), ), ); + + $schema['entity_test_revision'] = $schema['entity_test']; + $schema['entity_test_revision']['fields']['rid'] = array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + 'default' => NULL, + 'description' => 'The current revision ID of the entity.', + ); + $schema['entity_test_revision']['foreign keys']['name'] = array('entity_test_revision_type' => 'name'); + + $schema['entity_test_revision_revision'] = $schema['entity_test']; + $schema['entity_test_revision_revision']['fields']['rid'] = array( + 'type' => 'serial', + 'not null' => TRUE, + 'description' => 'Primary Key: Unique revision ID.', + ); + $schema['entity_test_revision_revision']['fields']['pid'] = array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + 'default' => NULL, + 'description' => 'The ID of the attached entity.', + ); + $schema['entity_test_revision_revision']['primary key'] = array('rid'); + $schema['entity_test_revision_revision']['foreign keys']['name'] = array('entity_test_revision_type' => 'name'); + + $schema['entity_test_revision_type'] = $schema['entity_test_type']; + return $schema; } diff --git a/tests/entity_test.module b/tests/entity_test.module index 4076a97..bf30a86 100644 --- a/tests/entity_test.module +++ b/tests/entity_test.module @@ -45,6 +45,40 @@ function entity_test_entity_info() { ), 'module' => 'entity_test', ), + + 'entity_test_revision' => array( + 'label' => t('Test Entity (revision support)'), + 'entity class' => 'EntityClassRevision', + 'controller class' => 'EntityAPIController', + 'base table' => 'entity_test_revision', + 'revision table' => 'entity_test_revision_revision', + 'fieldable' => TRUE, + 'entity keys' => array( + 'id' => 'pid', + 'revision' => 'rid', + 'bundle' => 'name', + ), + // 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', + ), + ), + 'entity_test_revision_type' => array( + 'label' => t('Test entity type'), + 'entity class' => 'Entity', + 'controller class' => 'EntityAPIControllerExportable', + 'base table' => 'entity_test_revision_type', + 'fieldable' => FALSE, + 'bundle of' => 'entity_test_revision', + 'exportable' => TRUE, + 'entity keys' => array( + 'id' => 'id', + 'name' => 'name', + ), + ), ); // Add bundle info but bypass entity_load() as we cannot use it here. @@ -141,6 +175,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_test_revision'); + } + +} /** * @@ -155,7 +199,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 +215,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); + } } /**