Commit 4a7bb638 authored by Dries's avatar Dries

- Patch #353458 by quicksketch, drewish, jpetso, sun, noahb, aaron, chx,...

- Patch #353458 by quicksketch, drewish, jpetso, sun, noahb, aaron, chx, mikey_p, dhthwy: hook_file_references() was not designed for a highly flexible field storage.
parent b36d4959
......@@ -495,6 +495,7 @@ function file_load_multiple($fids = array(), $conditions = array()) {
*
* @param $fid
* A file ID.
*
* @return
* A file object.
*
......@@ -542,7 +543,130 @@ function file_save(stdClass $file) {
}
/**
* Copy a file to a new location and adds a file record to the database.
* Determines where a file is used.
*
* @param $file
* A file object.
*
* @return
* A nested array with usage data. The first level is keyed by module name,
* the second by object type, the third has 'id' and 'count' keys.
*
* @see file_usage_add()
* @see file_usage_delete()
*/
function file_usage_list(stdClass $file) {
$result = db_select('file_usage', 'f')
->fields('f', array('module', 'type', 'id', 'count'))
->condition('fid', $file->fid)
->condition('count', 0, '>')
->execute();
$references = array();
foreach ($result as $usage) {
$references[$usage->module][$usage->type] = array('id' => $usage->id, 'count' => $usage->count);
}
return $references;
}
/**
* Records that a module is using a file.
*
* This usage information will be queried during file_delete() to ensure that
* a file is not in use before it is physically removed from disk.
*
* Examples:
* - A module that associates files with nodes, so $type would be
* 'node' and $id would be the node's nid. Files for all revisions are stored
* within a single nid.
* - The User module associates an image with a user, so $type would be 'user'
* and the $id would be the user's uid.
*
* @param $file
* A file object.
* @param $module
* The name of the module using the file.
* @param $type
* The type of the object that contains the referenced file.
* @param $id
* The unique, numeric ID of the object containing the referenced file.
* @param $count
* (optional) The number of references to add to the object. Defaults to 1.
*
* @see file_usage_list()
* @see file_usage_delete()
*/
function file_usage_add(stdClass $file, $module, $type, $id, $count = 1) {
db_merge('file_usage')
->key(array(
'fid' => $file->fid,
'module' => $module,
'type' => $type,
'id' => $id,
))
->fields(array('count' => $count))
->expression('count', 'count + :count', array(':count' => $count))
->execute();
}
/**
* Removes a record to indicate that a module is no longer using a file.
*
* The file_delete() function is typically called after removing a file usage
* to remove the record from the file_managed table and delete the file itself.
*
* @param $file
* A file object.
* @param $module
* The name of the module using the file.
* @param $type
* (optional) The type of the object that contains the referenced file. May
* be omitted if all module references to a file are being deleted.
* @param $id
* (optional) The unique, numeric ID of the object containing the referenced
* file. May be omitted if all module references to a file are being deleted.
* @param $count
* (optional) The number of references to delete from the object. Defaults to
* 1. 0 may be specified to delete all references to the file within a
* specific object.
*
* @see file_usage_add()
* @see file_usage_list()
* @see file_delete()
*/
function file_usage_delete(stdClass $file, $module, $type = NULL, $id = NULL, $count = 1) {
// Delete rows that have a exact or less value to prevent empty rows.
$query = db_delete('file_usage')
->condition('module', $module)
->condition('fid', $file->fid);
if ($type && $id) {
$query
->condition('type', $type)
->condition('id', $id);
}
if ($count) {
$query->condition('count', $count, '<=');
}
$result = $query->execute();
// If the row has more than the specified count decrement it by that number.
if (!$result) {
$query = db_update('file_usage')
->condition('module', $module)
->condition('fid', $file->fid);
if ($type && $id) {
$query
->condition('type', $type)
->condition('id', $id);
}
if ($count) {
$query->expression('count', 'count - :count', array(':count' => $count));
}
$query->execute();
}
}
/**
* Copies a file to a new location and adds a file record to the database.
*
* This function should be used when manipulating files that have records
* stored in the database. This is a powerful function that in many ways
......@@ -609,7 +733,7 @@ function file_copy(stdClass $source, $destination = NULL, $replace = FILE_EXISTS
}
/**
* Copy a file to a new location without invoking the file API.
* Copies a file to a new location without invoking the file API.
*
* This is a powerful function that in many ways performs like an advanced
* version of copy().
......@@ -982,30 +1106,30 @@ function file_create_filename($basename, $directory) {
/**
* Delete a file and its database record.
*
* If the $force parameter is not TRUE hook_file_references() will be called
* to determine if the file is being used by any modules. If the file is being
* used is the delete will be canceled.
* If the $force parameter is not TRUE, file_usage_list() will be called to
* determine if the file is being used by any modules. If the file is being
* used the delete will be canceled.
*
* @param $file
* A file object.
* @param $force
* Boolean indicating that the file should be deleted even if
* hook_file_references() reports that the file is in use.
* Boolean indicating that the file should be deleted even if the file is
* reported as in use by the file_usage table.
*
* @return mixed
* TRUE for success, FALSE in the event of an error, or an array if the file
* is being used by another module. The array keys are the module's name and
* the values are the number of references.
* is being used by any modules.
*
* @see file_unmanaged_delete()
* @see hook_file_references()
* @see file_usage_list()
* @see file_usage_delete()
* @see hook_file_delete()
*/
function file_delete(stdClass $file, $force = FALSE) {
// If any module returns a value from the reference hook, the file will not
// be deleted from Drupal, but file_delete will return a populated array that
// tests as TRUE.
if (!$force && ($references = module_invoke_all('file_references', $file))) {
// If any module still has a usage entry in the file_usage table, the file
// will not be deleted, but file_delete() will return a populated array
// that tests as TRUE.
if (!$force && ($references = file_usage_list($file))) {
return $references;
}
......@@ -1016,6 +1140,7 @@ function file_delete(stdClass $file, $force = FALSE) {
// database, so UIs can still find the file in the database.
if (file_unmanaged_delete($file->uri)) {
db_delete('file_managed')->condition('fid', $file->fid)->execute();
db_delete('file_usage')->condition('fid', $file->fid)->execute();
return TRUE;
}
return FALSE;
......
......@@ -265,38 +265,66 @@ function file_field_presave($entity_type, $entity, $field, $instance, $langcode,
}
}
/**
* Implements hook_field_insert().
*/
function file_field_insert($entity_type, $entity, $field, $instance, $langcode, &$items) {
list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
// Add a new usage of each uploaded file.
foreach ($items as $item) {
$file = (object) $item;
file_usage_add($file, 'file', $entity_type, $id);
}
}
/**
* Implements hook_field_update().
*
* Check for files that have been removed from the object.
* Checks for files that have been removed from the object.
*/
function file_field_update($entity_type, $entity, $field, $instance, $langcode, &$items) {
// On new revisions, old files are always maintained in the previous revision.
list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
// On new revisions, all files are considered to be a new usage and no
// deletion of previous file usages are necessary.
if (!empty($entity->revision)) {
foreach ($items as $item) {
$file = (object) $item;
file_usage_add($file, 'file', $entity_type, $id);
}
return;
}
// Build a display of the current FIDs.
$fids = array();
$current_fids = array();
foreach ($items as $item) {
$fids[] = $item['fid'];
$current_fids[] = $item['fid'];
}
// Get the current values in the entity, and delete files for removed items.
list($id) = entity_extract_ids($entity_type, $entity);
$original = clone $entity;
// Create a bare-bones entity so that we can load its previous values.
$original = entity_create_stub_entity($entity_type, array($id, $vid, $bundle));
field_attach_load($entity_type, array($id => $original), FIELD_LOAD_CURRENT, array('field_id' => $field['id']));
// Compare the original field values with the ones that are being saved.
$original_fids = array();
if (!empty($original->{$field['field_name']}[$langcode])) {
foreach ($original->{$field['field_name']}[$langcode] as $original_item) {
if (isset($original_item['fid']) && !in_array($original_item['fid'], $fids)) {
// For hook_file_references, remember that this is being deleted.
$original_item['file_field_name'] = $field['field_name'];
// Delete the file if possible.
file_field_delete_file($original_item, $field);
$original_fids[] = $original_item['fid'];
if (isset($original_item['fid']) && !in_array($original_item['fid'], $current_fids)) {
// Decrement the file usage count by 1 and delete the file if possible.
file_field_delete_file($original_item, $field, $entity_type, $id);
}
}
}
// Add new usage entries for newly added files.
foreach ($items as $item) {
if (!in_array($item['fid'], $original_fids)) {
$file = (object) $item;
file_usage_add($file, 'file', $entity_type, $id);
}
}
}
/**
......@@ -304,14 +332,10 @@ function file_field_update($entity_type, $entity, $field, $instance, $langcode,
*/
function file_field_delete($entity_type, $entity, $field, $instance, $langcode, &$items) {
list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
// Delete all file usages within this entity.
foreach ($items as $delta => $item) {
// For hook_file_references(), remember that this is being deleted.
$item['file_field_name'] = $field['field_name'];
// Pass in the ID of the object that is being removed so all references can
// be counted in hook_file_references().
$item['file_field_type'] = $entity_type;
$item['file_field_id'] = $id;
file_field_delete_file($item, $field);
file_field_delete_file($item, $field, $entity_type, $id, 0);
}
}
......@@ -319,32 +343,46 @@ function file_field_delete($entity_type, $entity, $field, $instance, $langcode,
* Implements hook_field_delete_revision().
*/
function file_field_delete_revision($entity_type, $entity, $field, $instance, $langcode, &$items) {
list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
foreach ($items as $delta => $item) {
// For hook_file_references, remember that this file is being deleted.
$item['file_field_name'] = $field['field_name'];
if (file_field_delete_file($item, $field)) {
// Decrement the file usage count by 1 and delete the file if possible.
if (file_field_delete_file($item, $field, $entity_type, $id)) {
$items[$delta] = NULL;
}
}
}
/**
* Check that File controls a file before attempting to delete it.
* Decrements a file usage count and attempts to delete it.
*
* This function only has an effect if the file being deleted is used only by
* File module.
*
* @param $item
* The field item that contains a file array.
* @param $field
* The field structure for the operation.
* @param $entity_type
* The type of $entity.
* @param $id
* The entity ID which contains the file being deleted.
* @param $count
* (optional) The number of references to decrement from the object
* containing the file. Defaults to 1.
*
* @return
* Boolean TRUE if the file was deleted, or an array of remaining references
* if the file is still in use by other modules. Boolean FALSE if an error
* was encountered.
*/
function file_field_delete_file($item, $field) {
// Remove the file_field_name and file_field_id properties so that references
// can be counted including the files to be deleted.
$field_name = isset($item['file_field_name']) ? $item['file_field_name'] : NULL;
$field_id = isset($item['file_field_id']) ? $item['file_field_id'] : NULL;
unset($item['file_field_name'], $item['file_field_id']);
function file_field_delete_file($item, $field, $entity_type, $id, $count = 1) {
// To prevent the file field from deleting files it doesn't know about, check
// the file reference count. Temporary files can be deleted because they
// are not yet associated with any content at all.
$file = (object) $item;
if ($file->status == 0 || file_get_file_reference_count($file, $field) > 0) {
$file->file_field_name = $field_name;
$file->file_field_id = $field_id;
$file_usage = file_usage_list($file);
if ($file->status == 0 || !empty($file_usage['file'])) {
file_usage_delete($file, 'file', $entity_type, $id, $count);
return file_delete($file);
}
......
......@@ -327,14 +327,6 @@ function file_progress_implementation() {
return $implementation;
}
/**
* Implements hook_file_references().
*/
function file_file_references($file) {
$count = file_get_file_reference_count($file, NULL, 'file');
return $count ? array('file' => $count) : NULL;
}
/**
* Implements hook_file_delete().
*/
......@@ -527,12 +519,9 @@ function file_managed_file_validate(&$element, &$form_state) {
if ($clicked_button != 'remove_button' && !empty($element['fid']['#value'])) {
if ($file = file_load($element['fid']['#value'])) {
if ($file->status == FILE_STATUS_PERMANENT) {
$reference_count = 0;
foreach (module_invoke_all('file_references', $file) as $module => $references) {
$reference_count += $references;
}
if ($reference_count == 0) {
form_error($element, t('Referencing to the file used in the !name field is not allowed.', array('!name' => $element['#title'])));
$references = file_usage_list($file);
if (empty($references)) {
form_error($element, t('The file used in the !name field may not be referenced.', array('!name' => $element['#title'])));
}
}
}
......@@ -942,73 +931,7 @@ function file_icon_map($file) {
*/
/**
* Count the number of times the file is referenced.
*
* @param $file
* A file object.
* @param $field
* (optional) A CCK field array or field name as a string. If provided,
* limits the reference check to the given field.
* @param $field_type
* (optional) The name of a field type. If provided, limits the reference
* check to fields of the given type.
* @return
* An integer value.
*/
function file_get_file_reference_count($file, $field = NULL, $field_type = NULL) {
// Determine the collection of fields to check.
if (isset($field)) {
// Support $field as 'field name'.
if (is_string($field)) {
$field = field_info_field($field);
}
$fields = array($field['field_name'] => $field);
}
else {
$fields = field_info_fields();
}
$types = entity_get_info();
$reference_count = 0;
foreach ($fields as $field) {
if (empty($field_type) || $field['type'] == $field_type) {
// TODO: Use a more efficient mechanism rather than actually retrieving
// all the references themselves, such as using a COUNT() query.
$references = file_get_file_references($file, $field, FIELD_LOAD_REVISION, $field_type);
foreach ($references as $entity_type => $type_references) {
$reference_count += count($type_references);
}
// If a field_name is present in the file object, the file is being deleted
// from this field.
if (isset($file->file_field_name) && $field['field_name'] == $file->file_field_name) {
// If deleting the entire piece of content, decrement references.
if (isset($file->file_field_type) && isset($file->file_field_id)) {
if ($file->file_field_type == $entity_type) {
$info = entity_get_info($entity_type);
$id = $types[$entity_type]['entity keys']['id'];
foreach ($type_references as $reference) {
if ($file->file_field_id == $reference->$id) {
$reference_count--;
}
}
}
}
// Otherwise we're just deleting a single reference in this field.
else {
$reference_count--;
}
}
}
}
return $reference_count;
}
/**
* Get a list of references to a file.
* Gets a list of references to a file.
*
* @param $file
* A file object.
......@@ -1020,8 +943,9 @@ function file_get_file_reference_count($file, $field = NULL, $field_type = NULL)
* FIELD_LOAD_REVISION to retrieve all references within all revisions or
* FIELD_LOAD_CURRENT to retrieve references only in the current revisions.
* @param $field_type
* Optional. The name of a field type. If given, limits the reference check to
* fields of the given type.
* (optional) The name of a field type. If given, limits the reference check
* to fields of the given type.
*
* @return
* An integer value.
*/
......
......@@ -169,7 +169,7 @@ class FileFieldTestCase extends DrupalWebTestCase {
* Assert that a file exists in the database.
*/
function assertFileEntryExists($file, $message = NULL) {
drupal_static_reset('file_load_multiple');
entity_get_controller('file')->resetCache();
$db_file = file_load($file->fid);
$message = isset($message) ? $message : t('File %file exists in database at the correct path.', array('%file' => $file->uri));
$this->assertEqual($db_file->uri, $file->uri, $message);
......@@ -187,7 +187,7 @@ class FileFieldTestCase extends DrupalWebTestCase {
* Assert that a file does not exist in the database.
*/
function assertFileEntryNotExists($file, $message) {
drupal_static_reset('file_load_multiple');
entity_get_controller('file')->resetCache();
$message = isset($message) ? $message : t('File %file exists in database at the correct path.', array('%file' => $file->uri));
$this->assertFalse(file_load($file->fid), $message);
}
......@@ -391,7 +391,7 @@ class FileFieldRevisionTestCase extends FileFieldTestCase {
// Attach the second file to a user.
$user = $this->drupalCreateUser();
$edit = array();
$edit = (array) $user;
$edit[$field_name][LANGUAGE_NONE][0] = (array) $node_file_r3;
user_save($user, $edit);
$this->drupalGet('user/' . $user->uid . '/edit');
......
......@@ -232,6 +232,13 @@ function image_field_presave($entity_type, $entity, $field, $instance, $langcode
file_field_presave($entity_type, $entity, $field, $instance, $langcode, $items);
}
/**
* Implements hook_field_insert().
*/
function image_field_insert($entity_type, $entity, $field, $instance, $langcode, &$items) {
file_field_insert($entity_type, $entity, $field, $instance, $langcode, $items);
}
/**
* Implements hook_field_update().
*/
......
......@@ -306,14 +306,6 @@ function image_file_delete($file) {
image_path_flush($file->uri);
}
/**
* Implements hook_file_references().
*/
function image_file_references($file) {
$count = file_get_file_reference_count($file, NULL, 'image');
return $count ? array('image' => $count) : NULL;
}
/**
* Implements hook_image_default_styles().
*/
......
......@@ -406,13 +406,9 @@ function hook_node_delete($node) {
* @ingroup node_api_hooks
*/
function hook_node_revision_delete($node) {
db_delete('upload')->condition('vid', $node->vid)->execute();
if (!is_array($node->files)) {
return;
}
foreach ($node->files as $file) {
file_delete($file);
}
db_delete('mytable')
->condition('vid', $node->vid)
->execute();
}
/**
......
......@@ -256,13 +256,13 @@ class FileHookTestCase extends FileTestCase {
$this->assertTrue(FALSE, t('Expected hooks %expected to be called but %uncalled was not called.', array('%expected' => implode(', ', $expected), '%uncalled' => implode(', ', $uncalled))));
}
else {
$this->assertTrue(TRUE, t('All the expected hooks were called: %expected', array('%expected' => implode(', ', $expected))));
$this->assertTrue(TRUE, t('All the expected hooks were called: %expected', array('%expected' => empty($expected) ? t('(none)') : implode(', ', $expected))));
}
// Determine if there were any unexpected calls.
$unexpected = array_diff($actual, $expected);
if (count($unexpected)) {
$this->assertTrue(FALSE, t('Unexpected hooks were called: %unexpected.', array('%unexpected' => implode(', ', $unexpected))));
$this->assertTrue(FALSE, t('Unexpected hooks were called: %unexpected.', array('%unexpected' => empty($unexpected) ? t('(none)') : implode(', ', $unexpected))));
}
else {
$this->assertTrue(TRUE, t('No unexpected hooks were called.'));
......@@ -1422,20 +1422,44 @@ class FileDeleteTest extends FileHookTestCase {
}
/**
* Try deleting a normal file (as opposed to a directory, symlink, etc).
* Tries deleting a normal file (as opposed to a directory, symlink, etc).
*/
function testNormal() {
function testUnused() {
$file = $this->createFile();
// Check that deletion removes the file and database record.
$this->assertTrue(is_file($file->uri), t("File exists."));
$this->assertIdentical(file_delete($file), TRUE, t("Delete worked."));
$this->assertFileHooksCalled(array('references', 'delete'));
$this->assertFalse(file_exists($file->uri), t("Test file has actually been deleted."));
$this->assertTrue(is_file($file->uri), t('File exists.'));
$this->assertIdentical(file_delete($file), TRUE, t('Delete worked.'));
$this->assertFileHooksCalled(array('delete'));
$this->assertFalse(file_exists($file->uri), t('Test file has actually been deleted.'));
$this->assertFalse(file_load($file->fid), t('File was removed from the database.'));
}
/**
* Tries deleting a file that is in use.
*/
function testInUse() {
$file = $this->createFile();
file_usage_add($file, 'testing', 'test', 1);
file_usage_add($file, 'testing', 'test', 1);
// TODO: implement hook_file_references() in file_test.module and report a
// file in use and test the $force parameter.
file_usage_delete($file, 'testing', 'test', 1);
file_delete($file);
$usage = file_usage_list($file);
$this->assertEqual($usage['testing']['test'], array('id' => 1, 'count' => 1), t('Test file is still in use.'));
$this->assertTrue(file_exists($file->uri), t('File still exists on the disk.'));
$this->assertTrue(file_load($file->fid), t('File still exists in the database.'));
// Clear out the call to hook_file_load().
file_test_reset();
file_usage_delete($file, 'testing', 'test', 1);
file_delete($file);
$usage = file_usage_list($file);
$this->assertFileHooksCalled(array('delete'));
$this->assertTrue(empty($usage), t('File usage data was removed.'));
$this->assertFalse(file_exists($file->uri), t('File has been deleted after its last usage was removed.'));
$this->assertFalse(file_load($file->fid), t('File was removed from the database.'));
}
}
......@@ -1537,7 +1561,7 @@ class FileMoveTest extends FileHookTestCase {
$this->assertTrue($result, t('File moved sucessfully.'));
// Check that the correct hooks were called.
$this->assertFileHooksCalled(array('move', 'update', 'delete', 'references', 'load'));
$this->assertFileHooksCalled(array('move', 'update', 'delete', 'load'));
// Reload the file from the database and check that the changes were
// actually saved.
......@@ -1886,6 +1910,108 @@ class FileSaveTest extends FileHookTestCase {
}
}
/**
* Tests file usage functions.
*/
class FileUsageTest extends FileTestCase {
function getInfo() {
return array(
'name' => 'File usage',
'description' => 'Tests the file usage functions.',
'group' => 'File',
);
}
/**
* Tests file_usage_list().
*/
function testGetUsage() {
$file = $this->createFile();
db_insert('file_usage')
->fields(array(
'fid' => $file->fid,
'module' => 'testing',
'type' => 'foo',
'id' => 1,
'count' => 1
))
->execute();
db_insert('file_usage')
->fields(array(
'fid' => $file->fid,
'module' => 'testing',
'type' => 'bar',
'id' => 2,
'count' => 2
))
->execute();
$usage = file_usage_list($file);
$this->assertEqual(count($usage['testing']), 2, t('Returned the correct number of items.'));
$this->assertEqual($usage['testing']['foo']['id'], 1, t('Returned the correct id.'));
$this->assertEqual($usage['testing']['bar']['id'], 2, t('Returned the correct id.'));
$this->assertEqual($usage['testing']['foo']['count'], 1, t('Returned the correct count.'));
$this->assertEqual($usage['testing']['bar']['count'], 2, t('Returned the correct count.'));
}
/**
* Tests file_usage_add().
*/
function testAddUsage() {