Pat Shaughnessy

Ribadesella, Spain

Paperclip sample app

April 30, 2009 · 50 comments

(Update October 2009)
I just updated a gem I wrote called View Mapper that will generate all of the code I describe below… you can use View Mapper to generate working scaffolding code that uploads/downloads files using Paperclip, or only view scaffolding code that works with an existing model in your app; for more details see: http://patshaughnessy.net/2009/10/16/paperclip-scaffolding

 

I love scaffolding. Many experienced Rails developers scoff at the idea of using scaffolding to generate Rails code: it’s ugly; it probably means you don’t understand how to write the code yourself; it generates a lot more code than you need, etc., etc. However, for a beginning Rails developer working on her/his own like me who isn’t surrounded by a team of Ruby experts, scaffolding is an essential tool and can help to get started in the right direction. Also, even for experienced Rubyists scaffolding can be a great way to quickly (minutes, not hours or days) get a simple app up and running to use for demos, UI wireframes, spiking some technical issue, etc.

This post will demonstrate how to use scaffolding to create a new Rails app from scratch that uses the Paperclip plugin to upload and display an image file. Feel free to copy/paste pieces of code from the narrative below and use them in your app, or you can just skip to the chase and get the finished version from github and run that on your machine.

There are a lot of other good tutorials out there about this; see:

I’ll take on the risk of repeating material that’s already out there in order to show how easy it is to get a working Paperclip application up and running using scaffolding. The fact that just a few commands and lines of code are required illustrates just how simple and powerful Paperclip’s design is. In my next post, I’ll proceed to change this sample app to demonstrate how to save the uploaded files in a database column instead of on the web server’s file system, using my modified version of Paperclip.

FYI At the time I wrote this, Rails was at version 2.3.2:

$ rails --version
Rails 2.3.2

Let’s get started by creating a new Rails application:

$ rails paperclip-sample-app
      create  
      create  app/controllers
      create  app/helpers
      create  app/models
      create  app/views/layouts
      create  config/environments
      create  config/initializers
      create  config/locales
      create  db
      create  doc
      create  lib
      create  lib/tasks
      create  log
      etc...

Before we go any farther, let’s setup our database.yml file and create a new MySQL database to use with the sample app. Replace the contents of config/database.yml with this:

development:
    adapter: mysql
    database: paperclip_sample_app_development
    username: root
    password: 
    host: localhost

Enter the proper username and password for MySQL if they are not “root” and null. And then run this from the command line:

$ cd paperclip-sample-app
$ rake db:create
(in /Users/pat/rails-apps/paperclip-sample-app)

Ok, now we have a MySQL database to work with. Next, let’s go ahead and install the Paperclip plugin. The best thing to do is just to get the latest version from github; Thoughtbot frequently updates it with bug fixes, enhancements, etc.:

$ ./script/plugin install git://github.com/thoughtbot/paperclip.git
Initialized empty Git repository in /Users/pat/rails-apps/paperclip-sample-app/vendor/plugins/paperclip/.git/
remote: Counting objects: 62, done.
remote: Compressing objects: 100% (50/50), done.
remote: Total 62 (delta 6), reused 39 (delta 4)
Unpacking objects: 100% (62/62), done.
From git://github.com/thoughtbot/paperclip
 * branch            HEAD       -> FETCH_HEAD

Now that we have an empty, shell application created and the Paperclip plugin installed, we can use scaffolding to add some working code to it. Let’s use the same “user” and “avatar” example Thoughtbot does on the Paperclip project page. The idea is that the sample will contain a table of users, and each user will have an avatar image displayed in the web site. So to get started, I’ll just create a new “user” model with string columns for the name and email address:

$ ./script/generate scaffold user name:string email:string
      exists  app/models/
      exists  app/controllers/
      exists  app/helpers/
      create  app/views/users
      exists  app/views/layouts/
      exists  test/functional/
      exists  test/unit/
      create  test/unit/helpers/
      exists  public/stylesheets/
      create  app/views/users/index.html.erb
      create  app/views/users/show.html.erb
      etc...

Now we need to generate the database columns necessary for Paperclip on our new model object using script/generate:

$ ./script/generate paperclip user avatar
      exists  db/migrate
      create  db/migrate/20090430084151_add_attachments_avatar_to_user.rb

And let’s go ahead and create the users table using db:migrate:

$ rake db:migrate
(in /Users/pat/rails-apps/paperclip-sample-app)
==  CreateUsers: migrating ====================================================
-- create_table(:users)
   -> 0.0031s
==  CreateUsers: migrated (0.0032s) ===========================================

==  AddAttachmentsAvatarToUser: migrating =====================================
-- add_column(:users, :avatar_file_name, :string)
   -> 0.0063s
-- add_column(:users, :avatar_content_type, :string)
   -> 0.0069s
-- add_column(:users, :avatar_file_size, :integer)
   -> 0.0085s
-- add_column(:users, :avatar_updated_at, :datetime)
   -> 0.0081s
==  AddAttachmentsAvatarToUser: migrated (0.0311s) ============================

You can see that the Paperclip generator created columns in the users table called “avatar_file_name,” “avatar_content_type,” “avatar_file_size” and “avatar_updated_at.” Now we have our database schema setup. The next step is to just modify the code that was generated for us by the scaffolding and make the changes necessary for Paperclip. The first thing to do is to add a line to the user model and indicate that it has a file attachment called “avatar.” To do this, open app/models/user.rb and just add this one line:

class User < ActiveRecord::Base
  has_attached_file :avatar
end

And then edit the new user form (app/views/users/new.html.erb) and add a file field to use to upload files. There are actually two code changes you need to make: first you need to set the HTML form to encode the uploaded file data (and other fields) using MIME multiple part syntax, and then second you need to actually add the file upload field. Here’s the finished new.html.erb file with these two changes in bold:

<h1>New user</h1>

<% form_for(@user, :html => { :multipart => true }) do |f| %>
  <%= f.error_messages %>

  <p>
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </p>
  <p>
    <%= f.label :email %><br />
    <%= f.text_field :email %>
  </p>
  <p>
    <%= f.label :avatar %><br />
    <%= f.file_field :avatar %>
  </p>
  <p>
    <%= f.submit 'Create' %>
  </p>
<% end %>

<%= link_to 'Back', users_path %>

Also make the same changes to the edit form that was generated by the scaffolding: app/views/users/edit.html.erb. The best thing to do would be to include the same ERB file (maybe called “_form.html.erb”) in both the new and edit form files. Ideally the scaffolding generator would have done this for us…

Now if we run our application we can upload an image file and attach it to a user:

If you submit this form, the image file will be uploaded to the server and saved on the file system. By default, Paperclip saves files inside a “system” folder it creates in your Rails app’s public folder. Let’s take a look at my public folder and see where the file went:

$ find public/system
public/system
public/system/avatars
public/system/avatars/1
public/system/avatars/1/original
public/system/avatars/1/original/mickey-mouse.jpg

This is one of the nice things about Paperclip: it just works. I don’t have to think about or worry about where the files are going to go; Thoughtbot has chosen simple default values that make sense. Here we can see that there are a series of folders created that correspond to the attachment name, model primary key and also the “style” of the attachment (more on that below).

If you want to or need to save the files in some other place on your server’s file system you can specify different options to has_attached_file in your model; see this write up for an example: http://travisonrails.com/2009/01/11/Changing-Paperclip-File-Storage-Location. Paperclip also supports saving the files in Amazon’s S3 storage service, and in my next post I’ll demonstrate how to save the file data inside the database itself, right in the users table in this example.

I’m almost done; now I just need to display the uploaded image somewhere; the simplest thing to do is just to add an image tag to the users show page. Again, my changes to the standard scaffolding code are in bold:

<p>
  <b>Name:</b>
  <%=h @user.name %>
</p>

<p>
  <b>Email:</b>
  <%=h @user.email %>
</p>

<p>
  <b>Avatar:</b>
  <%= image_tag @user.avatar.url %>
</p>

<%= link_to 'Edit', edit_user_path(@user) %> |
<%= link_to 'Back', users_path %>

Now we can see the image for our new user:

Since this image is bigger that what I would like, I can take advantage of Paperclip “styles” feature to generate a smaller version of it. To do that you will need to be sure you have ImageMagick installed on your server, which is what Paperclip uses behind the scenes to modify image files. Then all you need to do is just add two “styles” to your model, like this:

class User < ActiveRecord::Base
  has_attached_file :avatar,
                    :styles => {
                      :thumb => "75x75>",
                      :small => "150x150>"
                    }
end

The strings we pass in are actually options for ImageMagick's "convert" command; see it’s documentation for more details. And now in the show ERB we can just specify the “small” style in the image tag instead:

<%= image_tag @user.avatar.url(:small) %>

To see it, first I need to re-edit and re-upload the image (remember to add the file field code to edit.html.erb just like for new.html.erb):

Now when this form is submitted we will see the smaller image:

And let’s take another look at the public/system folder and see what Paperclip and ImageMagick have done for us:

$ find public/system
public/system
public/system/avatars
public/system/avatars/1
public/system/avatars/1/original
public/system/avatars/1/original/mickey-mouse.jpg
public/system/avatars/1/small
public/system/avatars/1/small/mickey-mouse.jpg
public/system/avatars/1/thumb
public/system/avatars/1/thumb/mickey-mouse.jpg

Again, this is very simple and just works! As a last step, let’s add the thumbnail image to the users index page so we can see Mickey without even clicking on that user record. This is as simple as editing app/views/users/index.html.erb and adding a new table column:

<table>
  <tr>
    <th>Photo</th>
    <th>Name</th>
    <th>Email</th>
  </tr>

<% @users.each do |user| %>
  <tr>
    <td><%= image_tag user.avatar.url(:thumb) %></td>
    <td><%=h user.name %></td>
    <td><%=h user.email %></td>
    <td><%= link_to 'Show', user %></td>
    <td><%= link_to 'Edit', edit_user_path(user) %></td>
    <td><%= link_to 'Destroy', user, :confirm => 'Are you sure?', :method => :delete %></td>
  </tr>
<% end %>
</table>

And now we just need to refresh the index page since the thumb image file was already generated:

And there you have it: a working file upload web site written in minutes. This was made possible by Rails scaffolding, and Paperclip's simple, elegant design.

50 comments Tags:·

Database storage for Paperclip: rewritten to use a single table

April 14, 2009 · 9 comments

Back in February I wrote an implementation of a new storage module for Paperclip that supports saving file attachments in a database table. My original implementation saved the file attachments in a separate database table, which was internally managed using a “has_many” relationship from the target model.

This month I decided to rewrite the code to use a simpler, single table approach. Instead of saving the files in a separate table, each file is saved in a BLOB column in the same table as the target model. This makes the database storage module easier and more intuitive to use, and moves it closer to the original intent of the Paperclip design.

Code: http://github.com/patshaughnessy/paperclip

New usage:

  1. Install and use my version of the plugin:
    script/plugin install git://github.com/patshaughnessy/paperclip.git
  2. In your model specify the "database" storage option; for example:
    has_attached_file :avatar, :storage => :database
    The file will be stored in a column called [attachment name]_file (e.g. "avatar_file") by default. To specify a different BLOB column name, use :column, like this:
    has_attached_file :avatar,
                      :storage => :database,
                      :column => 'avatar_data'
  3. If you have defined different styles, these files will be stored in additional columns called [attachment name]_[style name]_file (e.g. "avatar_thumb_file") by default. To specify different column names for each style, use :column in the style definition, like this:
  4. has_attached_file :avatar,
                      :storage => :database,
                      :styles => { 
                        :medium => {
                          :geometry => "300x300>",
                          :column => 'medium_file'
                        },
                        :thumb => {
                          :geometry => "100x100>",
                          :column => 'thumb_file'
                        }
                      }
  5. You need to create these new columns in your migrations or you'll get an exception. Example:
    add_column :users, :avatar_file, :binary
    add_column :users, :avatar_medium_file, :binary
    add_column :users, :avatar_thumb_file, :binary
    Note the migration for the "binary" column type will not work for LONGBLOBs in MySQL. Here's an example migration for MySQL:
    execute 'ALTER TABLE users ADD COLUMN avatar_file LONGBLOB'
    execute 'ALTER TABLE users ADD COLUMN avatar_medium_file LONGBLOB'
    execute 'ALTER TABLE users ADD COLUMN avatar_thumb_file LONGBLOB'
  6. To avoid performance problems loading all of the BLOB columns every time you access your ActiveRecord object, a class method is provided on your model called “select_without_file_columns_for.” This is set to a hash that will instruct ActiveRecord::Base.find to load all of the columns except the BLOB/file data columns, for example:
    {:select=>"id,name,avatar_file_name,avatar_content_type,..."}
    If you’re using Rails 2.3, you can specify this as a default scope:
    default_scope select_without_file_columns_for(:avatar)
    Or if you’re using Rails 2.1 or 2.2 you can use it to create a named scope:
    named_scope :without_file_data, select_without_file_columns_for(:avatar)
  7. By default, URLs will be set to this pattern:
    /:relative_root/:class/:attachment/:id?style=:style
    Example:
    /app-root-url/users/avatars/23?style=original
    The idea here is that to retrieve a file from the database storage, you will need some controller's code to be executed. Once you pick a controller to use for downloading, you can add this line to generate the download action for the default URL/action (the plural attachment name), "avatars" in this example:
    downloads_files_for :user, :avatar
    Or you can write a download method manually if there are security, logging or other requirements. If you prefer a different URL for downloading files you can specify that in the model; e.g.:
    has_attached_file :avatar,
                      :storage => :database,
                      :url =>'/users/show_avatar/:id/:style'
  8. Add a route for the download to the controller which will handle downloads, if necessary. The default URL, /:relative_root/:class/:attachment/:id?style=:style, will be matched by the default route: :controller/:action/:id

For now, I’ve overwritten my code from February. I believe this implementation is cleaner and easier to use; if anyone did download and use my code from February let me know and I can help you migrate the file data from the separate table back to columns in primary table. I will be writing a script to do this for my own application.

When I have time in the next few days or weeks, I’ll post a sample application that illustrates all of these steps and shows exactly how to do all of this. If anyone has any questions or suggestions please let me know.

9 comments Tags:·

Filtering auto_complete pick lists – part 2: using named scopes

April 02, 2009 · 1 comment

I just updated my customized version of the auto_complete plugin to allow you to provide a named scope to auto_complete_for, in order to filter the auto_complete pick list options differently than the plugin does by default. The updated code is now on github:

http://github.com/patshaughnessy/auto_complete

This is based on the ideas from my last post, Andrew Ng’s original post and my friend Alex’s suggestion to use named scopes instead of manually modifying the find options. Here’s an example of how to use it taken from the auto_complete sample app I posted in January:

  1. Add a named scope to your target model: For example suppose tasks belong to projects and have a named scope “by_project” which joins on the projects table and returns the tasks belonging to the project with the given name:
    class Task < ActiveRecord::Base
      belongs_to :project
      named_scope :by_project,
        lambda { |project_name| {
          :include => :project,
          :conditions => [ "projects.name = ?", project_name ]
        } }
    end
  2. In the controller, pass a block to auto_complete_for to specify that a named scope should be used to generate the competion options. Here the “by_project” named scope will be used to handle the task auto complete requests, using the “project” HTTP parameter:
    auto_complete_for :task, :name do | items, params |
      items.by_project(params['project'])
    end
  3. In the view, optionally specify additional parameters you might want to pass into your named scope: in my sample app I have a field called “project_name” elsewhere on my form:
    <% fields_for_task task do |f| -%>
      …
      <%= f.text_field_with_auto_complete :task,
            :name,
            {},
            {
              :method => :get,
              :with =>"value + '&project=' + $F('project_name')"
            } %>
      …
      <% end -%>

So how does this work? Let’s take a look at my new implementation of auto_complete_for:

def auto_complete_for(object, method, options = {})
  define_method("auto_complete_for_#{object}_#{method}") do
    model = object.to_s.camelize.constantize
    find_options = { 
      :conditions => [ "LOWER(#{model.quoted_table_name}.#{method}) LIKE ?",
        '%' + params[object][method].downcase + '%' ], 
      :order => "#{model.quoted_table_name}.#{method} ASC",
      :limit => 10 }.merge!(options)
    @items = model.scoped(find_options)
    @items = yield(@items, params) if block_given?
    render :inline => "<%= auto_complete_result @items, '#{method}' %>"
  end
end

One minor change I made here was to call “quoted_table_name” on the given model to specify the table name in the SQL generated to retrieve the auto complete results later. This was needed in case, like in my sample application, the controller specifies a named scope that joins with another table containing columns with the same name as the target model. If this isn’t the case, adding the table name to the SQL is harmless.

However, the most important 2 lines here are in bold: first we call a function called “scoped” to create an anonymous named scope based on the default auto_complete options “find_options:”

@items = model.scoped(find_options)

The exciting thing about this line, which Alex explained in his blog post, is that the use of named scopes delays the corresponding SQL statement from being executed until later when we actually access the query results in auto_complete_result. What happens instead is that an ActiveRecord:: NamedScope::Scope object is created, containing a temporary cache of the find options.

A good way to understand how this works is to try it in the Rails console:

complex-form-examples pat$ ./script/console 
Loading development environment (Rails 2.1.0)
>> find_options = { 
?>   :conditions => [ "LOWER(`tasks`.name) LIKE ?", '%t%' ], 
?>   :order => "`tasks`.name ASC",
?>   :limit => 10 }
=> {:order=>"name ASC", :conditions=>["LOWER(name) LIKE ?", "%t%"], :limit=>10}
>> Task.scoped(find_options)
=> [#<Task id: 4, project_id: 2, name: "Task 2a", due_at: nil, created_at: "2009-04-02 16:21:54",
updated_at: "2009-04-02 16:21:54">, #<Task id: 5, project_id: 2, name: "Task 2b", due_at: nil,
created_at: "2009-04-02 16:21:54", updated_at: "2009-04-02 16:21:54">, #<Task id: 6, project_id: 2,
name: "Task 2c", due_at: nil, created_at: "2009-04-02 16:21:54", updated_at: "2009-04-02
16:21:54">, #<Task id: 1, project_id: 1, name: "Task One", due_at: nil, created_at: "2009-04-02
16:21:30", updated_at: "2009-04-02 16:21:30">, #<Task id: 3, project_id: 1, name: "Task
Three", due_at: nil, created_at: "2009-04-02 16:21:30", updated_at: "2009-04-02 16:21:30">,
#<Task id: 2, project_id: 1, name: "Task Two", due_at: nil, created_at: "2009-04-02 16:21:30",
updated_at: "2009-04-02 16:21:30">]

Wait a minute! I thought the actual SQL execution was delayed by named scopes until I needed to access the results? Here the console has already displayed the query results, so the SQL statement must have been executed already. How and why did this happen? In this case, when you enter an expression into the Rails console and press ENTER, the expression is evaluated and then the “inspect” method is called on it. The problem is that the named scopes implementation has delegated the “inspect” method to another method, which executes the SQL statement and loads the query results.

We can use a trick in the console to open the ActiveRecord::NamedScope::Scope class and override the inspect method so the SQL is not executed, and prove to ourselves that “scoped()” actually does return a named scope object without executing the SQL statement:

>> module ActiveRecord
>> module NamedScope
>> class Scope
>> def inspect
>>   super # Avoids calling ActiveRecord::Base.find and calls Object.inspect
>> end
>> end
>> end
>> end
=> nil
>> Task.scoped(find_options)
=> #<ActiveRecord::NamedScope::Scope:0x21e65d4
      @proxy_options={:conditions=>["LOWER(`tasks`.name) LIKE ?", "%t%"],
          :order=>"`tasks`.name ASC", :limit=>10},
      @proxy_scope=Task(id: integer, project_id: integer, name: string,
          due_at: datetime, created_at: datetime, updated_at: datetime)>

So here we can see that “scoped” returns an ActiveRecord::NamedScope::Scope object, and that it has two interesting instance variables: proxy_scope and proxy_options. The first of these, proxy_options, contains the find options that were passed into the scoped() function, or into the “named_scope” declaration in your model. The second value, proxy_scope, indicates the parent scope or context in which this named scope object’s SQL statement should be run. In this example, that is the Task model itself. The named scope object is essentially a cache of the query options that will be user later when the query is executed.

Let’s see how this works in the auto_complete plugin. Back again to the new implementation of auto_complete_for, we have:

@items = model.scoped(find_options)
@items = yield(@items, params) if block_given?

The first line generates a ActiveRecord::NamedScope::Scope object, which is then passed into the block provided by the controller code, if any. Let’s take a look at my sample app’s implementation of the controller:

auto_complete_for :task, :name do | items, params |
  items.by_project(params['project'])
end

This is a good example of the second very cool feature of named scopes: that they are composable… in other words, that two or more named scopes can be combined together to form a single SQL statement that is executed only once! Let’s return to the same Rails console session with our redefined “inspect” method and see if we can understand a bit more about this:

>> Task.scoped(find_options).by_project 'Project One'
=> #<ActiveRecord::NamedScope::Scope:0x21d1864
  @proxy_options={
    :conditions=>["projects.name = ?", "Project One"],
    :include=>:project},
  @proxy_scope=
    #<ActiveRecord::NamedScope::Scope:0x21d1a30
      @proxy_options={
        :conditions=>["LOWER(`tasks`.name) LIKE ?", "%t%"],
        :order=>"`tasks`.name ASC",
        :limit=>10},
      @proxy_scope=Task(id: integer, project_id: integer, name: string, due_at: datetime, created_at: datetime, updated_at: datetime)
    >
  >

Now we can see that calling scoped(find_options).by_project just returns a chain of two named scopes: the first scope object with @proxy_scope set to the second one, and the second one with @proxy_scope set to the base model class. Later when this SQL query is executed, the code in NamedScope and ActiveRecord::Base will simply walk this chain of objects, accumulate the options into a single hash, convert the hash to SQL and execute it.

In auto_complete_for after the controller’s block returns, the “@items” value in auto_complete_for above is set to the parent/child named scope chain of objects, and then passed into auto_complete_result:

render :inline => "<%= auto_complete_result @items, '#{method}' %>"

Inside of auto_complete_result the @items value is used as if it were an array… like this:

def auto_complete_result(entries, field, phrase = nil)
  return unless entries
  items = entries.map { |entry|
    content_tag("li",
                phrase ? highlight(entry[field], phrase) : h(entry[field]))
  }
  content_tag("ul", items.uniq)
end

… to generate the HTML needed for the Prototype library’s implementation of the auto complete drop down box. The interesting thing here is that the SQL statement is executed as soon as the call to “map” is executed, accessing the elements of the “@items” array. This works because the ActiveRecord::NamedScope::Scope class redirects or delegates the [] and other array methods to ActiveRecord::Base.find. Here's the single SQL that is executed with the combined, accumulated query options:

SELECT `tasks`.`id` AS t0_r0, `tasks`.`project_id` AS t0_r1,
  `tasks`.`name` AS t0_r2, `tasks`.`due_at` AS t0_r3,
  `tasks`.`created_at` AS t0_r4, `tasks`.`updated_at` AS t0_r5,
  `projects`.`id` AS t1_r0, `projects`.`name` AS t1_r1,
  `projects`.`created_at` AS t1_r2, `projects`.`updated_at` AS t1_r3
FROM `tasks`
LEFT OUTER JOIN `projects` ON `projects`.id = `tasks`.project_id
WHERE ((projects.name = 'Project One') AND
  (LOWER(`tasks`.name) LIKE '%t%'))
ORDER BY `tasks`.name ASC LIMIT 10

In fact, in the original version of the auto_complete plugin before my changes to it for named scopes, the value passed into auto_complete_result was a simple array. The fact that named scopes are used now is entirely hidden from this code!

One last note here about named scope: as described in a comment in named_scope.rb from the Rails source code, the “proxy_options” method provides a convenient way to test the behavior of named scopes without actually checking the results of an actual SQL query. Here’s one of the tests I wrote for my new version of auto_complete_for:

def test_default_auto_complete_for
  get :auto_complete_for_some_model_some_field,
      :some_model => { :some_field => "some_value" }
  default_auto_complete_find_options = @controller.items.proxy_options
  assert_equal "`some_models`.some_field ASC", 
               default_auto_complete_find_options[:order]
  assert_equal 10, default_auto_complete_find_options[:limit]
  assert_equal ["LOWER(`some_models`.some_field) LIKE ?", "%some_value%"],
               default_auto_complete_find_options[:conditions]
end

Since I didn’t want to go to the trouble of setting up an actual in-memory database using SQLite, or to introduce mocha or some other mocking framework to the auto_complete tests, all I had to do was just call @controller.items.proxy_options and check that the find options are as expected. (I also had to expose “items” in the mock controller using attr_reader.) I have another test that checks that the controller’s block is called and it’s named scope options are present as expected… this test uses the proxy_scope method to walk up the chain to the parent named scope and get it’s proxy_options. See auto_complete_test.rb for details.

1 comment Tags:·