module FileBlobs::ActiveRecordExtensions::ClassMethods

Public Instance Methods

has_file_blob(attribute_name = 'file', options = {}) click to toggle source

Creates a reference to a FileBlob storing a file.

‘has_file_blob :file` creates the following

  • file - synthetic accessor

  • file_blob - belongs_to relationship pointing to a FileBlob

  • file_blob_id - attribute used by the belongs_to relationship; stores the SHA-256 of the file’s contents

  • file_size - attribute storing the file’s length in bytes; this is stored in the model as an optimization, so the length can be displayed / used for decisions without fetching the blob model storing the contents

  • file_mime_type - attribute storing the MIME type associated with the file; this is stored outside the blob model because it is possible to have the same bytes uploaded with different MIME types

  • file_original_name - attribute storing the name supplied by the browser that uploaded the file; this should not be trusted, as it is controlled by the user

@param [String] attribute_name the name of the relationship pointing to the

file blob, and the root of the names of the related attributes

@param [Hash{Symbol, Object}] options @option options [String] blob_model the name of the model used to store the

file's contents; defaults to 'FileBlob'

@option options [Boolean] allow_nil if true, allows saving a model without

an associated file
# File lib/file_blobs_rails/active_record_extensions.rb, line 37
  def has_file_blob(attribute_name = 'file', options = {})
    blob_model = (options[:blob_model] || 'FileBlob'.freeze).to_s
    allow_nil = options[:allow_nil] || false

    self.class_eval <<ENDRUBY, __FILE__, __LINE__ + 1
      # Saves the old blob model id, so the de-referenced blob can be GCed.
      before_save :#{attribute_name}_stash_old_blob, on: :update

      # Checks if the de-referenced FileBlob in an update should be GCed.
      after_update :#{attribute_name}_maybe_garbage_collect_old_blob

      # Checks if the FileBlob of a deleted entry should be GCed.
      after_destroy :#{attribute_name}_maybe_garbage_collect_blob

      # The FileBlob storing the file's content.
      belongs_to :#{attribute_name}_blob,
          { class_name: #{blob_model.inspect} }, -> { select :id }
      validates_associated :file_blob

      class #{attribute_name.to_s.classify}Proxy < FileBlobs::FileBlobProxy
        # Creates a proxy for the given model.
        #
        # The proxied model remains constant for the life of the proxy.
        def initialize(owner)
          @owner = owner
        end

        # Virtual attribute that proxies to the model's _blob attribute.
        #
        # This attribute does not have a corresponding setter because a _blob
        # setter would encourage sub-optimal code. The owner model's file blob
        # setter should be used instead, as it has a fast path that avoids
        # fetching the blob's data. By comparison, a _blob setter would always
        # have to fetch the blob data, to determine the blob's size.
        def blob
          @owner.#{attribute_name}_blob
        end

        # Virtual attribute that proxies to the model's _mime_type attribute.
        def mime_type
          @owner.#{attribute_name}_mime_type
        end
        def mime_type=(new_mime_type)
          @owner.#{attribute_name}_mime_type = new_mime_type
        end

        # Virtual attribute that proxies to the model's _original_name attribute.
        def original_name
          @owner.#{attribute_name}_original_name
        end
        def original_name=(new_name)
          @owner.#{attribute_name}_original_name = new_name
        end

        # Virtual getter that proxies to the model's _size attribute.
        #
        # This attribute does not have a corresponding setter because the _size
        # attribute automatically tracks the _data attribute, so it should not
        # be set on its own.
        def size
          @owner.#{attribute_name}_size
        end

        # Virtual attribute that proxies to the model's _blob_id attribute.
        #
        # This attribute is an optimization that allows some code paths to
        # avoid fetching the associated blob model. It should only be used in
        # these cases.
        #
        # This attribute does not have a corresponding setter because the
        # contents blob should be set using the model's _blob attribute (with
        # the blob proxy), which updates the model _size attribute and checks
        # that the blob is an instance of the correct blob model.
        def blob_id
          @owner.#{attribute_name}_blob_id
        end

        # Virtual attribute that proxies to the model's _data attribute.
        def data
          @owner.#{attribute_name}_data
        end
        def data=(new_data)
          @owner.#{attribute_name}_data = new_data
        end

        # Reflection.
        def blob_class
          #{blob_model}
        end
        def allow_nil?
          #{allow_nil}
        end
        attr_reader :owner
      end

      # Getter for the file's convenience proxy.
      def #{attribute_name}
        @_#{attribute_name}_proxy ||=
            #{attribute_name.to_s.classify}Proxy.new self
      end

      # Convenience setter for all the file attributes.
      #
      # @param {ActionDispatch::Http::UploadedFile, Proxy} new_file either an
      #     object representing a file uploaded to a controller, or an object
      #     obtained from another model's blob accessor
      def #{attribute_name}=(new_file)
        if new_file.respond_to? :read
          # ActionDispatch::Http::UploadedFile
          self.#{attribute_name}_mime_type = new_file.content_type
          self.#{attribute_name}_original_name = new_file.original_filename
          self.#{attribute_name}_data = new_file.read
        elsif new_file.respond_to? :blob_class
          # Blob owner proxy.
          self.#{attribute_name}_mime_type = new_file.mime_type
          self.#{attribute_name}_original_name = new_file.original_name
          if new_file.blob_class == #{blob_model}
            # Fast path: when the two files are backed by the same blob table,
            # we can create a new reference to the existing blob.
            self.#{attribute_name}_blob_id = new_file.blob_id
            self.#{attribute_name}_size = new_file.size
          else
            # Slow path: we need to copy data across blob tables.
            self.#{attribute_name}_data = new_file.data
          end
        elsif new_file.nil?
          # Reset everything to nil.
          self.#{attribute_name}_mime_type = nil
          self.#{attribute_name}_original_name = nil
          self.#{attribute_name}_data = nil
        else
          raise ArgumentError, "Invalid file_blob value: \#{new_file.inspect}"
        end
      end

      # Convenience getter for the file's content.
      #
      # @return [String] a string with the binary encoding that holds the
      #     file's contents
      def #{attribute_name}_data
        # NOTE: we're not using the ActiveRecord association on purpose, so
        #       that the large FileBlob doesn't hang off of the object
        #       referencing it; this way, the blob's data can be
        #       garbage-collected by the Ruby VM as early as possible
        if blob_id = #{attribute_name}_blob_id
          blob = #{blob_model}.where(id: blob_id).first!
          blob.data
        else
          nil
        end
      end

      # Convenience setter for the file's content.
      #
      # @param new_blob_contents [String] a string with the binary encoding
      #     that holds the new file contents to be stored by this model
      def #{attribute_name}_data=(new_blob_contents)
        sha = new_blob_contents && #{blob_model}.id_for(new_blob_contents)
        return if self.#{attribute_name}_blob_id == sha

        if sha && #{blob_model}.where(id: sha).length == 0
          self.#{attribute_name}_blob = #{blob_model}.new id: sha,
              data: new_blob_contents
        else
          self.#{attribute_name}_blob_id = sha
        end

        self.#{attribute_name}_size =
            new_blob_contents && new_blob_contents.bytesize
      end

      # Saves the old blob model id, so the de-referenced blob can be GCed.
      def #{attribute_name}_stash_old_blob
        @_#{attribute_name}_old_blob_id = #{attribute_name}_blob_id_change &&
            #{attribute_name}_blob_id_change.first
      end
      private :#{attribute_name}_stash_old_blob

      # Checks if the de-referenced blob model in an update should be GCed.
      def #{attribute_name}_maybe_garbage_collect_old_blob
        return unless @_#{attribute_name}_old_blob_id
        old_blob = #{blob_model}.find @_#{attribute_name}_old_blob_id
        old_blob.maybe_garbage_collect
        @_#{attribute_name}_old_blob_id = nil
      end
      private :#{attribute_name}_maybe_garbage_collect_old_blob

      # Checks if the FileBlob of a deleted entry should be GCed.
      def #{attribute_name}_maybe_garbage_collect_blob
        #{attribute_name}_blob && #{attribute_name}_blob.maybe_garbage_collect
      end
      private :#{attribute_name}_maybe_garbage_collect_blob

      unless self.respond_to? :file_blob_id_attributes
        @@file_blob_id_attributes = {}
        cattr_reader :file_blob_id_attributes, instance_reader: false
      end

      unless self.respond_to? :file_blob_eligible_for_garbage_collection?
        # Checks if a contents blob is referenced by a model of this class.
        #
        # @param {FileBlobs::BlobModel} file_blob a blob to be checked
        def self.file_blob_eligible_for_garbage_collection?(file_blob)
          attributes = file_blob_id_attributes[file_blob.class.name]
          file_blob_id = file_blob.id

          # TODO(pwnall): Use or to issue a single SQL query for multiple
          #     attributes.
          !attributes.any? do |attribute|
            where(attribute => file_blob_id).exists?
          end
        end
      end
ENDRUBY

    file_blob_id_attributes[blob_model] ||= []
    file_blob_id_attributes[blob_model] << :"#{attribute_name}_blob_id"

    if !allow_nil
      self.class_eval <<ENDRUBY, __FILE__, __LINE__ + 1
        validates :#{attribute_name}_blob, presence: true
        validates :#{attribute_name}_mime_type, presence: true
        validates :#{attribute_name}_size, presence: true
ENDRUBY
    end
  end