Project

shrine-rom

0.01
No release in over a year
Provides rom-rb integration for Shrine.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

Runtime

~> 5.0
~> 3.0
 Project Readme

Shrine::Plugins::Rom

Provides ROM integration for Shrine.

Installation

$ bundle add shrine-rom

Quick start

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.

Understanding

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 ...>

Updating

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

Attacher state

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

Persisting

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)

Backgrounding

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

Contributing

Tests are run with:

$ bundle exec rake test

Code of Conduct

Everyone interacting in the Shrine::Rom project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

License

MIT