Ruby on Rails: How to allow upload and download of files

This example shows how to attach a file to an item in the database.

Preparation

No special preparation is required.

Database

The item’s database schema should include the following columns:

Name Type
attachmentdata LONGBLOB
attachmenttype VARCHAR(64)
attachmentfilename VARCHAR(64) (or longer)

This can be achieved with the following migration:

def self.up
  create_table :items do |t|
    t.column :attachmentdata, :binary, :limit => 64 * 1024 * 1024
    t.column :attachmenttype, :string
    t.column :attachmentfilename, :string
    t.timestamps
  end
end

NOTE: If you change the column names, avoid using simply “type”. This will produce unexpected results and errors because Rails treats uses columns with this name to store object class names.

Model

The item’s model should include the following method, which allows the upload of a file to the database. Note that the method name is different from any of the column names. Think of it as a “virtual column”:

def attachment=(rhs)
    if ("" != rhs) then
      self.attachmentdata = rhs.read
      self.attachmenttype = rhs.content_type
      self.attachmentfilename = rhs.original_filename
    else
      self.attachmentdata = nil
      self.attachmenttype = nil
      self.attachmentfilename = nil
    end
end

View

The most important part of the view is to enable multipart in the form submitted when the item is created or edited note that the name of the file field matches the virtual column we created above:

<% form_for(@item, :html => {:multipart => true}) do |f| %>
<p>
  <label for="item_attachment">Attachment:</label>
  <%= f.file_field('attachment') %>
</p>
	<%= f.submit "Update" %>
<% end %>

To enable downloads, include links referencing the download action which we will define in the controller:

<%= link_to(@item.attachmentfilename, {:id => @item.id, :action => 'download'}) %>

Controller

Assuming the model is as described above, no changes are required to the controller for upload. For download, include the following action:

def download
    src = Source.find(params[:id])
    send_data(src.attachmentdata, :type => src.attachmenttype, :filename => src.attachmentfilename)
end

Improvements

Although the above is a simple way to upload and download files for storage in the database, it has one flaw. Normally, the edit view of the item displays input fields pre-filled with the current values. The user makes any changes, all values are sent to the server, and all the columns in the database can be re-written. Even if the user makes no changes, the current values are sent and used to update the columns.

However, attachments are not “pre-filled” in the edit view. If the user brings up an edit view and presses “Update” without making any changes, an empty filename is sent to the server, and the attachment=() method removes the attachment. This is unfriendly behavior.

A simple option is to display a Replace checkbox next to the file field in the edit view. By default, the checkbox is unchecked, meaning that the attachment is to remain untouched. If the user wants to replace the attachment, he must check the box as well as select a file. If the user wants to remove the attachment, he can check the box and leave the file field empty. This is accomplished with the following modifications to the view code shown above:

<% form_for(@item, :html => {:multipart => true}) do |f| %>
<p>
  <%= f.check_box(:replace_attachment) %> <label for="item_replace_attachment">Replace Attachment</label>
  <label for="item_attachment">Attachment:</label>
  <%= f.file_field('attachment') %>
</p>
	<%= f.submit "Update" %>
<% end %>

We don’t actually need to store the state of the checkbox in the database. It is purely a UI element. However, because forms are conveniently built from model objects, we should make the checkbox state a virtual attribute of the model class. As mentioned earlier, the checkbox is unchecked by default, so we add the following code to the model class:

  def replace_attachment
    false
  end

Naturally, you would think the body of attachment=() needs to check the state of the checkbox before altering the image columns. However, the attribute setter methods of model objects are called from ActiveRecord::Base.update_attributes(), and I know of no guarantee that they will be called in a specific order. It is therefore safer simply to store the state of the checkbox as well as the contents of the file field in the attribute setter methods, as follows:

  def replace_attachment=(rhs)
    @replace_attachment = rhs
  end

  def attachment=(rhs)
    @attachment = rhs
  end

The decision of whether or not to alter the attachment columns must be delayed to another method:

  def preprocess
    if @replace_attachment == '1'
      if (!@attachment.nil? && !@attachment.kind_of?(String))
        self.attachmentdata = @attachment.read
        self.attachmenttype = @attachment.content_type
        self.attachmentfilename = @attachment.original_filename
      else
        self.attachmentdata = nil
        self.attachmenttype = nil
        self.attachmentfilename = nil
      end
    end
  end

To ensure that the preprocess method is called after the model object’s attributes are updated from the form, but before the model object is saved to the database, simply add the following line to the top of your model class definition:

  before_save :preprocess

Tags: Technical

Created at: 7 December 2008 12:12 AM

NO COMMENTS ALLOWED