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..12cdffc 100644 --- a/entity.api.php +++ b/entity.api.php @@ -74,6 +74,9 @@ * - 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(). + * - active revision: (optional) The name of the entity property used by + * the entity CRUD API to determine if the newly-created revision should be + * set as the active revision. * - 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 +201,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 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 3e872fa..4c2d67f 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. */ @@ -122,7 +124,7 @@ function entity_object_load($entity_id, $entity_type) { * * @see entity_load() */ -function entity_load_single($entity_type, $id) { + function entity_load_single($entity_type, $id) { $entities = entity_load($entity_type, array($id)); return reset($entities); } @@ -234,6 +236,25 @@ 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 + * NULL if the entity does not support revisions. + */ +function entity_revision_delete($entity_type, $revision_id) { + $info = entity_get_info($entity_type); + if (in_array('EntityAPIControllerRevisionableInterface', class_implements($info['controller class']))) { + return entity_get_controller($entity_type)->revisionDelete($revision_id); + } + return NULL; +} + +/** * Create a new entity object. * * @param $entity_type diff --git a/entity.test b/entity.test index aaebc61..4812997 100644 --- a/entity.test +++ b/entity.test @@ -96,6 +96,106 @@ class EntityAPITestCase extends EntityWebTestCase { } /** + * Tests CRUD for entities support 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_test_revision'), + ); + field_create_field($field_info); + + $instance = array( + 'label' => 'Text Field', + 'field_name' => 'field_text', + 'entity_type' => 'entity_test_revision', + 'bundle' => 'main', + 'settings' => array(), + 'required' => FALSE, + ); + field_create_instance($instance); + + // Create a test entity. + $entity_first_revision = entity_create('entity_test_revision', array('title' => 'first revision', 'name' => 'main', 'uid' => 1)); + $entity_first_revision->field_text[LANGUAGE_NONE][0]['value'] = 'first revision text'; + entity_save('entity_test_revision', $entity_first_revision); + + $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_second_revision = clone $entity_first_revision; + $entity_second_revision->title = 'second revision'; + $entity_second_revision->is_new_revision = TRUE; + $entity_second_revision->field_text[LANGUAGE_NONE][0]['value'] = 'second revision text'; + unset($entity_second_revision->rid); + + entity_save('entity_test_revision', $entity_second_revision); + $this->assertNotEqual($entity_second_revision->rid, $entity_first_revision->rid, 'Saving an entity in revision mode creates a revision.'); + + // Check the saved entity. + $entity = current(entity_load('entity_test_revision', array($entity_first_revision->pid), array(), TRUE)); + $this->assertNotEqual($entity->title, $entity_first_revision->title, 'Current revision was changed.'); + + // Create third revision that is not active. + $entity_third_revision = clone $entity_first_revision; + $entity_third_revision->title = 'third revision'; + $entity_third_revision->is_new_revision = TRUE; + $entity_third_revision->active_revision = FALSE; + $entity_third_revision->field_text[LANGUAGE_NONE][0]['value'] = 'third revision text'; + unset($entity_third_revision->rid); + entity_save('entity_test_revision', $entity_third_revision); + $this->assertNotEqual($entity_second_revision->rid, $entity_third_revision->rid, 'Saving an entity in revision mode creates a revision.'); + + $entity = current(entity_load('entity_test_revision', array($entity_first_revision->pid), array(), TRUE)); + $this->assertEqual($entity->title, $entity_second_revision->title, 'Current revision was not changed.'); + $this->assertEqual($entity->field_text[LANGUAGE_NONE][0]['value'], $entity_second_revision->field_text[LANGUAGE_NONE][0]['value'], 'Current revision text field was not changed.'); + + // Save not active revision. + $entity_third_revision->title = 'third revision updated'; + $entity_third_revision->field_text[LANGUAGE_NONE][0]['value'] = 'third revision text updated'; + entity_save('entity_test_revision', $entity_third_revision); + + // Ensure that not active revision has been changed. + $entity = current(entity_load('entity_test_revision', array($entity_third_revision->pid), array('rid' => $entity_third_revision->rid), TRUE)); + $this->assertEqual($entity->title, 'third revision updated', 'Not active revision was updated successfully.'); + $this->assertEqual($entity->field_text[LANGUAGE_NONE][0]['value'], 'third revision text updated', 'Not active revision field was updated successfully.'); + + // Ensure that active revision has not been changed. + $entity = current(entity_load('entity_test_revision', array($entity_first_revision->pid), array(), TRUE)); + $this->assertEqual($entity->title, $entity_second_revision->title, 'Current revision was not changed.'); + + // Try to delete active revision. + $result = entity_revision_delete('entity_test_revision', $entity_second_revision->rid); + $this->assertFalse($result, 'Active revision cannot be deleted.'); + + // Make sure active revision is still second after trying to delete it. + $entity = current(entity_load('entity_test_revision', array($entity_first_revision->pid), array(), TRUE)); + $this->assertEqual($entity->rid, $entity_second_revision->rid, 'Second revision is still active.'); + + // Delete first revision. + $result = entity_revision_delete('entity_test_revision', $entity_first_revision->rid); + $this->assertTrue($result, 'Not active revision deleted.'); + + $entity = current(entity_load('entity_test_revision', array($entity_first_revision->pid), array('rid' => $entity_first_revision->rid), TRUE)); + $this->assertTrue(empty($entity), 'First revision deleted.'); + + // Delete the entity and make sure third revision has been deleted as well. + entity_delete('entity_test_revision', $entity_second_revision->pid); + $entity_info = entity_get_info('entity_test_revision'); + $result = db_select($entity_info['revision table']) + ->condition('rid', $entity_third_revision->rid) + ->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 1a2812e..a4c88f7 100644 --- a/includes/entity.controller.inc +++ b/includes/entity.controller.inc @@ -121,12 +121,48 @@ interface EntityAPIControllerInterface extends DrupalEntityControllerInterface { } /** + * Interface for EntityControllers of entities that support revisions. + */ +interface EntityAPIControllerRevisionableInterface extends EntityAPIControllerInterface { + + /** + * Delete revision. + * + * @param $revision_id + * Revision ID. + * + * @return boolean + * TRUE if the entity revision could be deleted, FALSE otherwise. + */ + public function revisionDelete($revision_id); + + /** + * Check whether active revision is recent. + * + * @param Entity $entity + * Entity to check. + * + * @return boolean + * TRUE in case revision is recent, FALSE otherwise. + */ + public function isActiveRevision($entity); + + /** + * Set a revision to be active + * + * @param Entity $entity + */ + public function setActiveRevision($entity); +} + +/** * 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 $activeRevisionKey; /** * Overridden. @@ -139,6 +175,9 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit $info = entity_get_info($this->entityInfo['bundle of']); $this->bundleKey = $info['bundle keys']['bundle']; } + + $this->activeRevisionKey = isset($this->entityInfo['entity keys']['active revision']) ? + $this->entityInfo['entity keys']['active revision'] : "active_revision"; } /** @@ -247,6 +286,22 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit $entities += $queried_entities; } + // If we load specific revision we should check whether it is active. + if ($revision_id) { + $record = current($entities); + if (isset($record->{$this->revisionKey})) { + $query = $this->buildQuery(array($record->{$this->idKey}), array($this->revisionKey => $record->{$this->revisionKey})); + $active_revision = $query->execute()->fetchAll(); + $record->{$this->activeRevisionKey} = !empty($active_revision); + } + } + // If we loaded multiple entities they are all active revisions. + else { + foreach ($entities as &$record) { + $record->{$this->activeRevisionKey} = TRUE; + } + } + // Entitycache module support: Add entities to the entity cache if we are // not loading a revision. if (!empty($this->entityInfo['entity cache']) && !empty($queried_entities) && !$revision_id) { @@ -288,7 +343,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)) { + // node_revision_delete() 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); } @@ -341,6 +400,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); @@ -360,6 +425,33 @@ 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 active revision. + if ($this->isActiveRevision($entity_revision)) { + return FALSE; + } + + db_delete($this->entityInfo['revision table']) + ->condition($this->revisionKey, $revision_id) + ->execute(); + + $this->invoke('revision_delete', $entity_revision); + return TRUE; + } + return FALSE; + } + + /** * Implements EntityAPIControllerInterface. * * @param $transaction @@ -378,19 +470,85 @@ 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); + // 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})) { + $entity->{$this->revisionKey} = NULL; + $entity->revision = TRUE; + } + + $return = FALSE; + // Create new entity. + if (empty($entity->{$this->idKey}) || !empty($entity->is_new)) { + // For new entities, create the row in the base table, then save the + // revision. + if (!empty($entity->is_new)) { + $return = drupal_write_record($this->entityInfo['base table'], $entity); + } + if (!empty($this->revisionKey)) { + $this->saveRevision($entity); + $update_base_table = TRUE; + } + $this->invoke('insert', $entity); + } + // Update entity including creating new revision. + else { + // 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->{$this->activeRevisionKey})) ? $entity->{$this->activeRevisionKey} : $update_base_table_default; + } + + // If we create new or update not active revision we should have proper + // revision id in base table before invoking 'update' for Field API. + if ((isset($entity->is_new_revision) && $entity->is_new_revision) || (isset($entity->{$this->revisionKey}) && $entity->{$this->revisionKey} != $entity->original->{$this->revisionKey})) { + db_update($this->entityInfo['base table']) + ->fields(array($this->revisionKey => $entity->{$this->revisionKey})) + ->condition($this->idKey, $entity->{$this->idKey}) + ->execute(); + } + $this->resetCache(array($entity->{$this->idKey})); $this->invoke('update', $entity); + + if ((isset($entity->is_new_revision) && $entity->is_new_revision) || (isset($entity->{$this->revisionKey}) && $entity->{$this->revisionKey} != $entity->original->{$this->revisionKey})) { + // Restore original revision id in base table. + if (empty($update_base_table)) { + db_update($this->entityInfo['base table']) + ->fields(array($this->revisionKey => $entity->original->{$this->revisionKey})) + ->condition($this->idKey, $entity->{$this->idKey}) + ->execute(); + // Invoke field_attach_update hook once again so Field API will + // restore field values of original revision. + if (!empty($this->entityInfo['fieldable'])) { + $this->resetCache(array($entity->{$this->idKey})); + field_attach_update($this->entityType, $entity->original); + } + } + else { + $update_base_table = FALSE; + } + } } - else { - $return = drupal_write_record($this->entityInfo['base table'], $entity); - $this->invoke('insert', $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; } @@ -402,6 +560,42 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit } /** + * Save revision. + * + * @param Entity $entity + * Entity revision to save. + * + * @return boolean + * TRUE if the entity revision could be saved, FALSE otherwise. + */ + 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; + } + drupal_write_record($this->entityInfo['revision table'], $entity, $this->revisionKey); + } + + /** + * Check whether this is the active revision of the entity. + * + * @param $entity + * + * @return Boolean + */ + public function isActiveRevision($entity) { + return $entity->{$this->activeRevisionKey}; + } + + /** + * Set the entity as the active revision + */ + public function setActiveRevision($entity) { + $entity->{$this->activeRevisionKey} = TRUE; + return $this; + } + + /** * Implements EntityAPIControllerInterface. */ public function create(array $values = array()) { diff --git a/includes/entity.inc b/includes/entity.inc index 90a53c4..b8cbdc7 100644 --- a/includes/entity.inc +++ b/includes/entity.inc @@ -268,6 +268,23 @@ class Entity { } /** + * Is this the active revision + * + * return Boolean + */ + public function isActiveRevision() { + return entity_get_controller($this->entityType)->isActiveRevision($this); + } + + /** + * Set the revision to be Active + */ + public function setActive() { + entity_get_controller($this->entityType)->setActiveRevision($this); + return $this; + } + + /** * Magic method to only serialize what's necessary. */ public function __sleep() { diff --git a/tests/entity_feature.module b/tests/entity_feature.module index c2c9fbf..563087b 100644 --- a/tests/entity_feature.module +++ b/tests/entity_feature.module @@ -30,3 +30,17 @@ 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, + )); + + return $types; +} diff --git a/tests/entity_test.install b/tests/entity_test.install index dce2161..2761897 100644 --- a/tests/entity_test.install +++ b/tests/entity_test.install @@ -121,6 +121,47 @@ 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']['fields']['title'] = array( + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ); + + $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']['fields']['title'] = array( + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ); + $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..b30305b 100644 --- a/tests/entity_test.module +++ b/tests/entity_test.module @@ -45,6 +45,41 @@ 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', + 'set active revision' => 'set_active_revision', + ), + // 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 +176,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 +200,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 +216,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); + } } /**