softace/paperclip_database

View on GitHub
lib/paperclip/storage/database.rb

Summary

Maintainability
A
1 hr
Test Coverage
module Paperclip
  module Storage
    # Store files in a database.
    #
    # Usage is identical to the file system storage version, except:
    #
    # 1. In your model specify the "database" storage option; for example:
    #   has_attached_file :avatar, :storage => :database
    #
    # The files will be stored in a new database table named with the plural attachment name
    # by default, "avatars" in this example.
    #
    # 2. You need to create this new storage table with at least these columns:
    #   - file_contents
    #   - style
    #   - the primary key for the parent model (e.g. user_id)
    #
    # Note the "binary" migration will not work for the LONGBLOG type in MySQL for the
    # file_cotents column. You may need to craft a SQL statement for your migration,
    # depending on which database server you are using. Here's an example migration for MySQL:
    #
    # create_table :avatars do |t|
    #   t.string :style
    #   t.integer :user_id
    #   t.timestamps
    # end
    # execute 'ALTER TABLE avatars ADD COLUMN file_contents LONGBLOB'
    #
    # You can optionally specify any storage table name you want and whether or not deletion is done by cascading or not as follows:
    #   has_attached_file :avatar, :storage => :database, :database_table => 'avatar_files', :cascade_deletion => true
    #
    # 3. 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'
    #
    # 4. 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
    #
    module Database

      def self.extended(base)
        base.instance_eval do
          setup_attachment_class
          setup_paperclip_file_model
          setup_paperclip_files_association
          override_default_options base
        end
        Paperclip.interpolates(:database_path) do |attachment, style|
          attachment.database_path(style)
        end
        Paperclip.interpolates(:relative_root) do |attachment, style|
          begin
            if ActionController::AbstractRequest.respond_to?(:relative_url_root)
              relative_url_root = ActionController::AbstractRequest.relative_url_root
            end
          rescue NameError
          end
          if !relative_url_root && ActionController::Base.respond_to?(:relative_url_root)
            ActionController::Base.relative_url_root
          end
        end

        ActiveRecord::Base.logger.info("[paperclip] Database Storage Initalized.")
      end

      def setup_paperclip_files_association
        @paperclip_files_association_name = @paperclip_file_model.name.demodulize.tableize
        @database_table = @paperclip_file_model.table_name
        #FIXME: This fails when using  set_table_name "<myname>" in your model
        #FIXME: This should be fixed in ActiveRecord...
        instance.class.has_many(@paperclip_files_association_name.to_sym,
                                :class_name => @paperclip_file_model.name,
                                :foreign_key => instance.class.table_name.classify.underscore + '_id'
                                )
      end
      private :setup_paperclip_files_association

      def setup_paperclip_file_model
        class_name = "#{instance.class.table_name.singularize}_#{name.to_s}_paperclip_file".classify
        if @attachment_class.const_defined?(class_name, false)
          @paperclip_file_model = @attachment_class.const_get(class_name, false)
        else
          @paperclip_file_model = @attachment_class.const_set(class_name, Class.new(::ActiveRecord::Base))
          @paperclip_file_model.table_name = @options[:database_table] || name.to_s.pluralize
          @paperclip_file_model.validates_uniqueness_of :style, :scope => instance.class.table_name.classify.underscore + '_id'
          case ActiveModel::VERSION::MAJOR
          when 3, 4
            @paperclip_file_model.scope :file_for, lambda {|style| @paperclip_file_model.where('style = ?', style) }
          when 5,6
            @paperclip_file_model.scope :file_for, lambda {|style| where('style = ?', style) }
          else
            raise "ActiveModel version #{ActiveModel::VERSION::STRING} is not supported (yet)"
          end
        end
      end
      private :setup_paperclip_file_model

      def setup_attachment_class
        instance.class.ancestors.each do |ancestor|
          # Pick the top-most definition like
          # Paperclip::AttachmentRegistry#definitions_for
          names_for_ancestor = ancestor.attachment_definitions.keys rescue []
          if names_for_ancestor.member?(name)
            @attachment_class = ancestor
          end
        end
      end
      private :setup_attachment_class

      def copy_to_local_file(style, dest_path)
        File.open(dest_path, 'wb+'){|df| to_file(style).tap{|sf| File.copy_stream(sf, df); sf.close;sf.unlink} }
      end

      def override_default_options(base)
        if @options[:url] == base.class.default_options[:url]
          @options[:url] = ":relative_root/:class/:attachment/:id?style=:style"
        end
        @options[:path] = ":database_path"
      end
      private :override_default_options

      def database_table
        @database_table
      end

      def database_path(style)
        paperclip_file = file_for(style)
        if paperclip_file
          "#{database_table}(id=#{paperclip_file.id},style=#{style.to_s})"
        else
          "#{database_table}(id=new,style=#{style.to_s})"
        end
      end

      def exists?(style = default_style)
        if original_filename
          instance.send("#{@paperclip_files_association_name}").where(:style => style).exists?
        else
          false
        end
      end

      # Returns representation of the data of the file assigned to the given
      # style, in the format most representative of the current storage.
      def to_file style = default_style
        if @queued_for_write[style]
          @queued_for_write[style]
        elsif exists?(style)
          tempfile = Tempfile.new instance_read(:file_name)
          tempfile.binmode
          tempfile.write file_contents(style)
          tempfile.flush
          tempfile.rewind
          tempfile
        else
          nil
        end

      end
      alias_method :to_io, :to_file

      def files
        instance.send("#{@paperclip_files_association_name}")
      end

      def file_for(style)
        db_result = instance.send("#{@paperclip_files_association_name}").send(:file_for, style.to_s)
        raise RuntimeError, "More than one result for #{style}" if db_result.size > 1
        db_result.first
      end

      def file_contents(style = default_style)
        file_for(style).file_contents
      end

      def flush_writes
        ActiveRecord::Base.logger.info("[paperclip] Writing files for #{name}")
        @queued_for_write.each do |style, file|
            case ActiveModel::VERSION::MAJOR
            when 3
              paperclip_file = instance.send(@paperclip_files_association_name).send(:find_or_create_by_style, style.to_s)
            when 4,5,6
              paperclip_file = instance.send(@paperclip_files_association_name).send(:find_or_create_by, style: style.to_s)
            else
              raise "ActiveModel version #{ActiveModel::VERSION::STRING} is not supported (yet)"
            end
          paperclip_file.file_contents = file.read
          paperclip_file.save!
          instance.reload
        end
        @queued_for_write = {}
      end

      def flush_deletes #:nodoc:
        ActiveRecord::Base.logger.info("[paperclip] Deleting files for #{name}")
        @queued_for_delete.uniq! ##This is apparently necessary for paperclip v 3.x
        @queued_for_delete.each do |path|
          /id=([0-9]+)/.match(path)
          if @options[:cascade_deletion] && !instance.class.exists?(instance.id)
            raise RuntimeError, "Deletion has not been done by through cascading." if @paperclip_file_model.exists?($1)
          else
            @paperclip_file_model.destroy $1
          end
        end
        @queued_for_delete = []
      end

      module ControllerClassMethods
        def self.included(base)
          base.extend(self)
        end
        def downloads_files_for(model, attachment, options = {})
          define_method("#{attachment.to_s.pluralize}") do
            #FIXME: Handling Namespaces
            model_record = Object.const_get(model.to_s.camelize.to_sym, false).find(params[:id])
            style = params[:style] ? params[:style] : 'original'
            send_data model_record.send(attachment).file_contents(style),
                      :filename => model_record.send("#{attachment}_file_name".to_sym),
                      :type => model_record.send("#{attachment}_content_type".to_sym)
          end
        end
      end
    end
  end
end