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.api.php b/entity.api.php index 3844fdf..28f2785 100644 --- a/entity.api.php +++ b/entity.api.php @@ -73,6 +73,8 @@ * - 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(). + * - set active revision: (optional) When new revision is created, property + * with this name sets newly created revision to be active. * - 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. @@ -197,6 +199,8 @@ function entity_crud_hook_entity_info() { * this type. * - deletion callback: (optional) A callback that permanently deletes an * entity of this type. + * - deletion revision 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 08e4662..4e41a62 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('EntityAPIControllerRevisionableInterface', 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..e5308bb 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->set_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 6999e12..5c61d7e 100644 --- a/includes/entity.controller.inc +++ b/includes/entity.controller.inc @@ -121,12 +121,39 @@ 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 current revision is recent. + * + * @param Entity $entity + * Entity to check. + * + * @return boolean + * TRUE in case revision is recent, FALSE otherwise. + */ + public function isCurrentRevision($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 $setActiveRevisionKey; /** * Overridden. @@ -139,6 +166,9 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit $info = entity_get_info($this->entityInfo['bundle of']); $this->bundleKey = $info['bundle keys']['bundle']; } + if (isset($this->entityInfo['entity keys']['set active revision'])) { + $this->setActiveRevisionKey = $this->entityInfo['entity keys']['set active revision']; + } } /** @@ -247,6 +277,22 @@ class EntityAPIController extends DrupalDefaultEntityController implements Entit $entities += $queried_entities; } + // If we load specific revision we should check whether it is current. + if ($revision_id) { + $record = current($entities); + if (isset($record->{$this->revisionKey})) { + $query = $this->buildQuery(array($record->{$this->idKey}), array($this->revisionKey => $record->{$this->revisionKey})); + $current_revision = $query->execute()->fetchAll(); + $record->currentRevision = !empty($current_revision); + } + } + // If we loaded multiple entities they are all current revisions. + else { + foreach ($entities as &$record) { + $record->currentRevision = 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 +334,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); } @@ -342,6 +392,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); @@ -361,6 +417,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 current revision. + if ($this->isCurrentRevision($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 @@ -379,19 +462,84 @@ 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})) { + unset($entity->{$this->revisionKey}); + } + + $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 = (isset($entity->{$this->setActiveRevisionKey})) ? $entity->{$this->setActiveRevisionKey} : 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->setActiveRevisionKey})) ? $entity->{$this->setActiveRevisionKey} : $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; } @@ -403,6 +551,32 @@ 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; + } + 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) { + return $entity->currentRevision; + } + + /** * Implements EntityAPIControllerInterface. */ public function create(array $values = array()) { 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); + } } /**