Search code examples
ruby-on-railsrails-activestorage

Can Rails Active Storage map to a pre-existing Images table in the database?


I'm working on a legacy Rails app, recently upgraded to Rails 5.2. It already has a roll-your-own Image implementation with uploads, and there are millions of images already in the DB. The Image model belongs_to other models; each of them either has_many :images or has_one :image.

Here's the schema of the Image table:

  create_table "images", id: :integer, unsigned: true, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
    t.datetime "created_at"
    t.datetime "updated_at"
    t.string "content_type", limit: 100
    t.integer "user_id"
    t.date "when"
    t.text "notes"
    t.string "copyright_holder", limit: 100
    t.integer "license_id", default: 1, null: false
    t.integer "num_views", default: 0, null: false
    t.datetime "last_view"
    t.integer "width"
    t.integer "height"
    t.float "vote_cache"
    t.boolean "ok_for_export", default: true, null: false
    t.string "original_name", limit: 120, default: ""
    t.boolean "transferred", default: false, null: false
    t.boolean "gps_stripped", default: false, null: false
  end

Part of the implementation is that there's a subclass of Image, Image::Url, that gets the image's URL (on AWS), based on the size requested, of the various sizes already generated by ImageMagick.

class Image
  class Url
    SUBDIRECTORIES = {
      full_size: "orig",
      huge: "1280",
      large: "960",
      medium: "640",
      small: "320",
      thumbnail: "thumb"
    }.freeze

    SUBDIRECTORY_TO_SIZE = {
      "orig" => :full_size,
      "1280" => :huge,
      "960" => :large,
      "640" => :medium,
      "320" => :small,
      "thumb" => :thumbnail
    }.freeze

    attr_accessor :size, :id, :transferred, :extension

    def initialize(args)
      size = args[:size]
      size = SUBDIRECTORY_TO_SIZE[size] unless size.is_a?(Symbol)
      size = :full_size if size == :original
      self.size        = size
      self.id          = args[:id]
      self.transferred = args[:transferred]
      self.extension   = args[:extension]
    end

    def url
      for source in source_order
        return source_url(source) if source_exists?(source)
      end
      source_url(fallback_source)
    end

    def source_exists?(source)
      spec = format_spec(source, :test)
      case spec
      when :transferred_flag
        transferred
      when /^file:/
        local_file_exists?(spec)
      when /^http:/
        remote_file_exists?(spec)
      when /^https:/
        remote_file_exists?(spec)
      else
        raise("Invalid image source test spec for "\
              "#{source.inspect}: #{spec.inspect}")
      end
    end

    def local_file_exists?(spec)
      File.exist?(file_name(spec)[7..])
    end

    def remote_file_exists?(spec)
      url = URI.parse(file_name(spec))
      result = Net::HTTP.new(url.host, url.port).request_head(url.path)
      result.code == 200
    end

    def source_url(source)
      file_name(format_spec(source, :read))
    end

    def file_name(path)
      "#{path}/#{subdirectory}/#{id}.#{extension}"
    end

    def subdirectory
      SUBDIRECTORIES[size] || raise("Invalid size: #{size.inspect}")
    end

    def source_order
      OurApp.image_precedence[size] || OurApp.image_precedence[:default]
    end

    def fallback_source
      OurApp.image_fallback_source
    end

    def format_spec(source, mode)
      spec = specs(source)[mode]
      spec.is_a?(String) ? format(spec, root: OurApp.root) : spec
    end

    def specs(source)
      OurApp.image_sources[source] ||
        raise("Missing image source: #{source.inspect}")
    end
  end
end

So I'm considering whether our existing data tables could be somehow migrated to work with Active Storage (which is not installed on this Rails app).

EDIT One question suggested by the above class, which I did not write, is whether our established image URL structure would be compatible with Active Storage. Would this "url generator" work even if the actual source url info is migrated to the blobs? It's possible i'm not understanding how AWS storage works, maybe there is no real "source" url.

All the tutorials and explainers I've found discuss installing Active Storage on a new Rails app, or using it to add attachments to existing models. That's not my situation -- I already have an Image model relating to a dozen other models, and they already know what their "attachments" i.e. Image relations, are.

My question is, can Active Storage somehow make use of that existing Image table and its relations -- or is Active Storage more properly understood as an alternative to this roll-your-own setup, that cannot integrate with it.

There is an SO question about "Rails Active Storage without model" that seems to imply that a mapping between Active Storage and a model can occur. What i don't understand is the relationship betwen Active Storage and an existing Image model. As far as I understand, it doesn't make sense that the Image model would has_one_attached or has_many_attached (as a User or Product would have attachments) - it IS already a model of the attachment itself. Or am I getting that wrong?


Solution

  • The heart of ActiveStorage is really three tables (and models) which correspond somewhat to your images table:

    class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
      def change
        # Use Active Record's configured type for primary and foreign keys
        primary_key_type, foreign_key_type = primary_and_foreign_key_types
    
        create_table :active_storage_blobs, id: primary_key_type do |t|
          t.string   :key,          null: false
          t.string   :filename,     null: false
          t.string   :content_type
          t.text     :metadata
          t.string   :service_name, null: false
          t.bigint   :byte_size,    null: false
          t.string   :checksum,     null: false
    
          if connection.supports_datetime_with_precision?
            t.datetime :created_at, precision: 6, null: false
          else
            t.datetime :created_at, null: false
          end
    
          t.index [ :key ], unique: true
        end
    
        create_table :active_storage_attachments, id: primary_key_type do |t|
          t.string     :name,     null: false
          t.references :record,   null: false, polymorphic: true, index: false, type: foreign_key_type
          t.references :blob,     null: false, type: foreign_key_type
    
          if connection.supports_datetime_with_precision?
            t.datetime :created_at, precision: 6, null: false
          else
            t.datetime :created_at, null: false
          end
    
          t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
          t.foreign_key :active_storage_blobs, column: :blob_id
        end
    
        create_table :active_storage_variant_records, id: primary_key_type do |t|
          t.belongs_to :blob, null: false, index: false, type: foreign_key_type
          t.string :variation_digest, null: false
    
          t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true
          t.foreign_key :active_storage_blobs, column: :blob_id
        end
      end
    
      private
        def primary_and_foreign_key_types
          config = Rails.configuration.generators
          setting = config.options[config.orm][:primary_key_type]
          primary_key_type = setting || :primary_key
          foreign_key_type = setting || :bigint
          [primary_key_type, foreign_key_type]
        end
    end
    

    As you can see from the migration it uses the active_storage_blobs to store the actual information about the file that is stored. A blob can also have multiple variants.

    active_storage_attachments joins blobs with resources (the model attaching the attachment) through a polymorphic assocation. This lets you add has_one_attached/has_many_attached to any model in your application without adding any additional database columns or tables.

    So I'm considering whether Active Storage (which is not installed on this Rails app) could work with this existing setup.

    Lets put it this way - you should not expect that you can just plug and play your legacy data into ActiveStorage. Its an extremely opinionated peice of software thats mainly designed around the goal of being able to plugged into a any number of models with a minimum of configuration.

    ActiveStorage can probally work fine in tandem with your existing setup (replacing it for new records) but replacing the legacy code with AS will most likely entail some heavy data migration and you'll also need a pretty good understanding of how AS works.

    What i don't understand is the relationship betwen Active Storage and an existing Image model.

    Thats because there is none really. ActiveSupport::Attachment and ActiveSupport::Blob serve the same role for all the models in your Rails app which have attachments. Its not designed with legacy support in mind.