Provides ROM integration for Shrine.
$ bundle add shrine-rom
Let's asume we have "photos" that have an "image" attachment. We start by
configuring Shrine in our initializer, and loading the rom
plugin provided by
shrine-rom:
# Gemfile
gem "shrine", "~> 3.0"
gem "shrine-rom"
require "shrine"
require "shrine/storage/file_system"
Shrine.storages = {
cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"), # temporary
store: Shrine::Storage::FileSystem.new("public", prefix: "uploads"), # permanent
}
Shrine.plugin :rom # ROM integration, provided by shrine-rom
Shrine.plugin :cached_attachment_data # for retaining the cached file across form redisplays
Shrine.plugin :rack_file # for accepting Rack uploaded file hashes
Shrine.plugin :form_assign # for assigning file from form fields
Shrine.plugin :restore_cached_data # re-extract metadata when attaching a cached file
Shrine.plugin :validation_helpers # for validating uploaded files
Shrine.plugin :determine_mime_type # determine MIME type from file content
Next, we run a migration that adds an image_data
text or JSON column to our
photos
table:
ROM::SQL.migration do
change do
add_column :photos, :image_data, :text # or :jsonb
end
end
Now we can define an ImageUploader
class and include an attachment module
into our Photo
entity:
class ImageUploader < Shrine
# we add some basic validation
Attacher.validate do
validate_max_size 20*1024*1024
validate_mime_type %w[image/jpeg image/png image/webp]
validate_extension %w[jpg jpeg png webp]
end
end
class PhotoRepo < ROM::Repository[:photos]
commands :create, update: :by_pk, delete: :by_pk
struct_namespace Entities
def find(id)
photos.fetch(id)
end
end
module Entities
class Photo < ROM::Struct
include ImageUploader::Attachment[:image]
end
end
Let's now add fields for our image
attachment to our HTML form for creating
photos:
# with Forme gem:
form @photo, action: "/photos", enctype: "multipart/form-data", namespace: "photo" do |f|
f.input :title, type: :text
f.input :image, type: :hidden, value: @attacher&.cached_data
f.input :image, type: :file
f.button "Create"
end
Now in our controller we can attach the uploaded file from request params. We'll assume you're using dry-validation for validating user input.
post "/photos" do
@photo = Entities::Photo.new
@attacher = @photo.image_attacher
@attacher.form_assign(params["photo"]) # assigns file and performs validation
contract = CreatePhotoContract.new(image_attacher: @attacher)
result = contract.call(params["photo"])
if result.success?
@attacher.finalize # upload cached file to permanent storage
attributes = result.to_h
attributes.merge!(@attacher.column_values)
photo_repo.create(attributes)
# ...
else
# ... render view with form ...
end
end
class CreatePhotoContract < Dry::Validation::Contract
option :image_attacher
params do
required(:title).filled(:string)
end
# copy any attacher's validation errors into our dry-validation contract
rule(:image) do
key.failure("must be present") unless image_attacher.attached?
image_attacher.errors.each { |message| key.failure(message) }
end
end
Once the image has been successfully attached to our photo, we can retrieve the
image URL by calling #image_url
on the entity:
<img src="<%= @photo.image_url %>" />
If you want to see a complete example with direct uploads and backgrounding, see the demo app.
The rom
plugin builds upon Shrine's entity
plugin, providing
persistence functionality.
The attachment module included into the entity provides convenience methods for reading the data attribute:
photo.image_data #=> '{"id":"path/to/file","storage":"store","metadata":{...}}'
photo.image #=> #<Shrine::UploadedFile @id="path/to/file" @storage_key=:store ...>
photo.image_url #=> "https://s3.amazonaws.com/..."
photo.image_attacher #=> #<Shrine::Attacher ...>
When updating the attached file for an existing record, it's important to
initialize the attacher from that record's current attachment. That way the old
file will be automatically deleted on Attacher#finalize
.
photo = photo_repo.find(photo_id)
photo.image #=> #<Shrine::UploadedFile @id="foo" ...>
attacher = photo.image_attacher # has current attachment
attacher.assign(file)
photo_repo.update(photo_id, attacher.column_values)
attacher.finalize # deletes previous attachment
Unlike the model
plugin, the entity
plugin doesn't memoize the
Shrine::Attacher
instance:
photo.image_attacher #=> #<Shrine::Attacher:0x00007ffe564085d8>
photo.image_attacher #=> #<Shrine::Attacher:0x00007ffe53b2f378> (different instance)
So, if you want to update the attacher state, you need to first assign it to a variable:
attacher = photo.image_attacher
attacher.assign(file)
attacher.finalize
Normally you'd persist attachment changes explicitly, by using
Attacher#column_data
or Attacher#column_values
:
attacher = photo.image_attacher
attacher.attach(file)
photo_repo.create(image_data: attacher.column_data)
# or
photo_repo.create(attacher.column_values)
If you want to delay promotion into a background job, you need to call
Attacher#finalize
after you've persisted the cached file, so that your
background job is able to retrieve the record. We'll assume your repository
objects are registered using dry-container.
Shrine.plugin :backgrounding
Shrine::Attacher.promote_block do
Attachment::PromoteJob.perform_async(self.class.name, record.class.name, record.id, name, file_data)
end
Shrine::Attacher.destroy_block do
Attachment::DestroyJob.perform_async(self.class.name, data)
end
attacher = photo.image_attacher
attacher.assign(file)
photo = photo_repo.create(attacher.column_values)
attacher.finalize # calls the promote block
class Attachment::PromoteJob
include Sidekiq::Worker
def perform(attacher_class, entity_class, entity_id, name, file_data)
attacher_class = Object.const_get(attacher_class)
# entity_class is your custom ROM::Struct entity class name.
# generate repo_registry_name from entity_class.
repo = Application[repo_registry_name] # retrieve repo from container
entity = repo.find(entity_id)
attacher = attacher_class.retrieve(
entity: entity,
name: name,
file: file_data,
repository: repo, # repository needs to be passed in and it should be the last parameter
)
attacher.atomic_promote
rescue Shrine::AttachmentChanged, # attachment has changed
ROM::TupleCountMismatchError # record has been deleted
end
end
class Attachment::DestroyJob
include Sidekiq::Worker
def perform(attacher_class, data)
attacher = Object.const_get(attacher_class).from_data(data)
attacher.destroy
end
end
Tests are run with:
$ bundle exec rake test
Everyone interacting in the Shrine::Rom project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.