Skip to main content

Multiple Files

There are times when you want to allow users to attach multiple files to a single resource, like an album having many photos or a playlist having many songs. Some file attachment libraries provide a special interface for multiple attachments, but Shrine doesn't have one, because it's more flexible to use the "nested attributes" feature of your ORM directly to implement this.

The basic idea is to create a separate table that will have a many-to-one relationship with the main table, and files will be attached on the records in the new table. That way each record from the main table can implicitly have multiple attachments through the associated records.

album1
photo1
- attachment1
photo2
- attachment2
photo3
- attachment3

album2
photo4
- attachment4
photo5
- attachment5

...

To illustrate, this code will create an album with three photos using nested attributes:

Album.create(  title: "My Album",  photos_attributes: [  { image: File.open("image1.jpg", binmode: true) },  { image: File.open("image2.jpg", binmode: true) },  { image: File.open("image3.jpg", binmode: true) },  ] )

This design gives you the greatest flexibility, allowing you to support:

  • adding new attachments
  • updating existing attachments
  • removing existing attachments
  • sorting attachments (via a separate position column)
  • having additional fields on attachments (e.g. captions, votes, number of downloads etc.)
  • expanding this to be "many-to-many" relation (e.g. create different playlists from a list of songs, etc)
  • ...

How to Implement

For the rest of this guide, we will use the example where we have "albums" that can have multiple "photos" in it. The main table is the albums table and the files (or attachments) table will be the photos table.

1. Create the main resource and attachment table

Let's create a table for the main resource and attachments, and add a foreign key in the attachment table for the main table:

Sequel.migration do  change do  create_table :albums do  primary_key :id   String :title  end   create_table :photos do  primary_key :id  foreign_key :album_id, :albums   String :image_data  end  end end

In the Photo model, create a Shrine attachment attribute named image (:image matches the _data column prefix above):

class Photo < Sequel::Model  include ImageUploader::Attachment(:image) end

2. Declare nested attributes

Using nested attributes is the easiest way to implement any dynamic "one-to-many" association. In the Album model we'll declare a one-to-many relationship to the photos table, and allow it to directly accept attributes for the associated photo records by enabling nested attributes:

class Album < Sequel::Model  one_to_many :photos  plugin :association_dependencies, photos: :destroy # destroy photos when album is destroyed   plugin :nested_attributes  nested_attributes :photos, destroy: true end

Documentation on nested attributes:

3. Create the View

Create a form like you normally do to create the album. To this form we'll add a file field for selecting photos, which will have multiple attribute to allow the user to select multiple files. We'll also display nested fields for already created photos, so that the same form can be used for updating the album/photos as well (they will be submitted under the album[photos_attributes] parameter).

form_for @album, html: { enctype: "multipart/form-data" } do |f|  f.text_field :title  f.fields_for :photos do |p| # adds new `album[photos_attributes]` parameter  p.hidden_field :image, value: p.object.cached_image_data, id: nil  p.file_field :image  p.check_box :_destroy unless p.object.new_record?  end  file_field_tag "files[]", multiple: true  f.submit "Create" end

In your controller you should still be able to assign all the attributes to the album, just remember to whitelist the new parameter for the nested attributes, in this case album[photos_attributes].

4a. Form upload

If you would like to avoid writing JavaScript, you can have selected files uploaded via the form, and then in your controller you can assign them in the format of nested attributes. You'll want to merge these newly uploaded photos with the existing photos params that will come from the nested form fields. This could look something like this:

# In your controller:  # transform the list of uploaded files into a photos attributes hash new_photos_attributes = params[:files].inject({}) do |hash, file|  hash.merge!(SecureRandom.hex => { image: file }) end  # merge new photos attributes with existing (`album_params` is whitelisted `params[:album]`) photos_attributes = album_params[:photos_attributes].to_h.merge(new_photos_attributes) album_attributes = album_params.merge(photos_attributes: photos_attributes)  # create the album with photos Album.create(album_attributes)

In this case you need to make sure your form tag has enctype="multipart/form-data" attributes specified, otherwise form upload won't succeed.

4b. Direct upload

Alternatively, you can have selected files immediately uploaded to a direct upload endpoint, as soon as they're selected. It's recommended to use Uppy for handling client side file uploads. There are two methods of implementing direct uploads: to your app using the upload_endpoint plugin, or directly to a storage like S3 using the presign_endpoint plugin. See the walkthroughs for setting up simple direct app uploads and direct S3 uploads, as well as the Roda or Rails demo app which implement multiple file uploads.

Once files are uploaded asynchronously, you can dynamically insert photo attachment fields for the image attachment attribute into the form, where the hidden field is filled with uploaded file data in JSON format, just like when doing single direct uploads. The attachment field names should be namespaced according to the convention that the nested attributes feature expects. In this case it should be album[photos_attributes][<uid>], where <uid> is any unique string.

# naming format in which photos fields should be generated and submitted album[photos_attributes][11111][image] = '{"id":"38k25.jpg","storage":"cache","metadata":{...}}' album[photos_attributes][29323][image] = '{"id":"sg0fg.jpg","storage":"cache","metadata":{...}}' album[photos_attributes][34820][image] = '{"id":"041jd.jpg","storage":"cache","metadata":{...}}'

In your controller you don't need to make any changes to handle the nested photos attributes (other than allowing them), your ORM will take care of creating, updating, or removing the associated photos.

Album.create(album_params) # or `params[:album]`

5. Adding Validations

You can add file validations to the Photo model using the validation_helpers plugin. You just need to make sure that your ORM is configured to automatically validate associated records.

class ImageUploader < Shrine  plugin :validation_helpers  plugin :determine_mime_type   Attacher.validate do  validate_max_size 10*1024*1024  validate_mime_type %w[image/jpeg image/png image/webp]  end end
class Album < Sequel::Model  # ... (nested_attributes already enables validating associated photos) ... end

Note that by default only metadata set on the client side will be available for validations. Shrine will not automatically run metadata extraction for directly uploaded files, as this would require (at least partially) downloading each file from storage, which could be an unwanted performance penalty. If you want metadata to be extracted on the server side, you can load the restore_cached_data plugin which will make metadata extraction run on assignment, or you can extract the metadata manually (e.g. in a background job) using the refresh_metadata plugin.

Conclusion

Now we have a simple interface for accepting multiple attachments, which internally uses the nested attributes feature of your ORM to create multiple associated records, each with a single attachment. After creation you can also add new attachments, or update and delete existing ones, which the nested attributes feature gives you for free.