From 42995b1787e0882c2b80d25b4fde1bac474dd0f2 Mon Sep 17 00:00:00 2001 From: Mitsuhiro Shibuya Date: Sun, 12 Jan 2025 18:55:58 +0900 Subject: [PATCH] Support CarrierWave 3.x --- .github/workflows/ci.yml | 4 +- carrierwave-mongoid.gemspec | 2 +- gemfiles/carrierwave-3.0.gemfile | 5 + gemfiles/carrierwave-3.1.gemfile | 5 + lib/carrierwave/mongoid.rb | 415 +++++++++++------- .../mongoid/mount_uploaders_spec.rb | 17 +- spec/mongoid_spec.rb | 17 +- 7 files changed, 298 insertions(+), 167 deletions(-) create mode 100644 gemfiles/carrierwave-3.0.gemfile create mode 100644 gemfiles/carrierwave-3.1.gemfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac7e4a3..1010d75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,8 +8,8 @@ jobs: mongodb: [4.4] ruby: [2.7, "3.0", 3.1, 3.2, 3.3] gemfile: - - carrierwave-2.1 - carrierwave-2.2 + - carrierwave-3.1 - mongoid-7 - mongoid-8 - mongoid-9 @@ -20,6 +20,8 @@ jobs: - { mongodb: "4.4", ruby: "2.6", gemfile: "carrierwave-1.2" } - { mongodb: "4.4", ruby: "2.6", gemfile: "carrierwave-1.3" } - { mongodb: "4.4", ruby: "2.6", gemfile: "carrierwave-2.0" } + - { mongodb: "4.4", ruby: "2.6", gemfile: "carrierwave-2.1" } + - { mongodb: "4.4", ruby: "2.6", gemfile: "carrierwave-3.0" } - { mongodb: "4.4", ruby: "2.6", gemfile: "mongoid-3" } - { mongodb: "4.4", ruby: "2.6", gemfile: "mongoid-4" } - { mongodb: "4.4", ruby: "2.6", gemfile: "mongoid-5" } diff --git a/carrierwave-mongoid.gemspec b/carrierwave-mongoid.gemspec index e761198..6910ce6 100644 --- a/carrierwave-mongoid.gemspec +++ b/carrierwave-mongoid.gemspec @@ -20,7 +20,7 @@ Gem::Specification.new do |s| s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } s.require_paths = ["lib"] - s.add_dependency "carrierwave", [">= 0.8", "< 3"] + s.add_dependency "carrierwave", [">= 0.8", "< 4"] s.add_dependency "mongoid", [">= 3.0", "< 10.0"] s.add_dependency "mongoid-grid_fs", [">= 1.3", "< 3.0"] s.add_dependency "mime-types", "< 3" if RUBY_VERSION < "2.0" # mime-types 3+ doesn't support ruby 1.9 diff --git a/gemfiles/carrierwave-3.0.gemfile b/gemfiles/carrierwave-3.0.gemfile new file mode 100644 index 0000000..1ebc5e8 --- /dev/null +++ b/gemfiles/carrierwave-3.0.gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" + +gem "carrierwave", "~> 3.0.0" + +gemspec path: "../" diff --git a/gemfiles/carrierwave-3.1.gemfile b/gemfiles/carrierwave-3.1.gemfile new file mode 100644 index 0000000..8f0cdc2 --- /dev/null +++ b/gemfiles/carrierwave-3.1.gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" + +gem "carrierwave", "~> 3.1.0" + +gemspec path: "../" diff --git a/lib/carrierwave/mongoid.rb b/lib/carrierwave/mongoid.rb index 1e9f375..5e76227 100644 --- a/lib/carrierwave/mongoid.rb +++ b/lib/carrierwave/mongoid.rb @@ -8,124 +8,46 @@ module CarrierWave module Mongoid include CarrierWave::Mount - ## - # See +CarrierWave::Mount#mount_uploader+ for documentation - # - def mount_uploader(column, uploader=nil, options={}, &block) - field options[:mount_on] || column - super + module CarrierWave3 + def mount_uploader(column, uploader=nil, options={}, &block) + field options[:mount_on] || column - alias_method :read_uploader, :read_attribute - alias_method :write_uploader, :write_attribute - public :read_uploader - public :write_uploader - - include CarrierWave::Validations::ActiveModel - - validates_integrity_of column if uploader_option(column.to_sym, :validate_integrity) - validates_processing_of column if uploader_option(column.to_sym, :validate_processing) - - after_save :"store_#{column}!" - before_save :"write_#{column}_identifier" - after_destroy :"remove_#{column}!" - if Gem::Version.new(CarrierWave::VERSION) >= Gem::Version.new("1.0.beta") - before_update :"store_previous_changes_for_#{column}" - else - before_update :"store_previous_model_for_#{column}" + super end - after_save :"remove_previously_stored_#{column}" - class_eval <<-RUBY, __FILE__, __LINE__+1 - def #{column}=(new_file) - column = _mounter(:#{column}).serialization_column - - # We're using _new_ and _old_ placeholder values to force Mongoid to - # recognize changes in embedded documents. Before we assign these - # values, we need to store the original file name in case we need to - # delete it when document is saved. - previous_uploader_value = read_uploader(column) - @_previous_uploader_value_for_#{column} = previous_uploader_value - - # mongoid won't upload a new file if there was no file previously. - write_uploader(column, '_old_') if self.persisted? && read_uploader(column).nil? - - send(:"\#{column}_will_change!") - super - end + def mount_uploaders(column, uploader = nil, options = {}, &block) + field (options[:mount_on] || column), type: Array, default: [] - def remove_#{column}=(arg) - if ['1', true].include?(arg) - column = _mounter(:#{column}).serialization_column - send(:"\#{column}_will_change!") - end - super - end + super + end - def remove_#{column}! - super unless respond_to?(:paranoid?) && paranoid? && flagged_for_destroy? - end + private - # Overrides Mongoid's default dirty behavior to instead work more like - # ActiveRecord's. Mongoid doesn't deem an attribute as changed unless - # the new value is different than the original. Given that CarrierWave - # caches files before save, it's necessary to know that there's a - # pending change even though the attribute value itself might not - # reflect that yet. - def #{column}_changed? - changed_attributes.has_key?("#{column}") - end + def mount_base(column, uploader=nil, options={}, &block) + super - # The default Mongoid attribute_will_change! method is not enough - # when we want to upload a new file in an existing embedded document. - # The custom version of that method forces the callbacks to be - # ran and so does the upload. - def #{column}_will_change! - changed_attributes["#{column}"] = '_new_' - end + alias_method :read_uploader, :read_attribute + alias_method :write_uploader, :write_attribute + public :read_uploader + public :write_uploader - # Since we had to use tricks with _old_ and _new_ values to properly - # track changes in embedded documents, we need to overwrite this method - # to remove the original file if it was replaced with a new one that - # had a different name. - if Gem::Version.new(CarrierWave::VERSION) >= Gem::Version.new("1.0.beta") - def remove_previously_stored_#{column} - before, after = @_previous_changes_for_#{column} - # Don't delete if the files had the same name - return if before.nil? && after.nil? - # Proceed to remove the file, use the original name instead of '_new_' - before = @_previous_uploader_value_for_#{column} || before - _mounter(:#{column}).remove_previous([before], [after]) - end - end + include CarrierWave::Validations::ActiveModel - # CarrierWave 1.1 references ::ActiveRecord constant directly which - # will fail in projects without ActiveRecord. We need to overwrite this - # method to avoid it. - # See https://github.com/carrierwaveuploader/carrierwave/blob/07dc4d7bd7806ab4b963cf8acbad73d97cdfe74e/lib/carrierwave/mount.rb#L189 - def store_previous_changes_for_#{column} - @_previous_changes_for_#{column} = changes[_mounter(:#{column}).serialization_column] - end + validates_integrity_of column if uploader_option(column.to_sym, :validate_integrity) + validates_processing_of column if uploader_option(column.to_sym, :validate_processing) + validates_download_of column if uploader_option(column.to_sym, :validate_download) - def find_previous_model_for_#{column} - if self.embedded? - ancestors = - if self.respond_to?(:_association) # Mongoid >= 7.0.0.beta - [[ self._association.key, self._parent ]].tap { |x| x.unshift([ x.first.last._association.key, x.first.last._parent ]) while x.first.last.embedded? } - elsif self.respond_to?(:__metadata) # Mongoid >= 4.0.0.beta1 < 7.0.0.beta - [[ self.__metadata.key, self._parent ]].tap { |x| x.unshift([ x.first.last.__metadata.key, x.first.last._parent ]) while x.first.last.embedded? } - else # Mongoid < 4.0.0.beta1 - [[ self.metadata.key, self._parent ]].tap { |x| x.unshift([ x.first.last.metadata.key, x.first.last._parent ]) while x.first.last.embedded? } - end - first_parent = ancestors.first.last - reloaded_parent = first_parent.class.unscoped.find(first_parent.to_key.first) - association = ancestors.inject(reloaded_parent) { |parent,(key,ancestor)| (parent.is_a?(Array) ? parent.find(ancestor.to_key.first) : parent).send(key) } - association.is_a?(Array) ? association.find(to_key.first) : association - else - self.class.unscoped.for_ids(to_key).first - end - end + after_save :"store_#{column}!" + before_save :"write_#{column}_identifier" + after_update :"remove_previously_stored_#{column}" + after_save :"reset_previous_changes_for_#{column}" + after_update :"mark_remove_#{column}_false" + after_destroy :"remove_#{column}!" + mod = Module.new + prepend mod + mod.class_eval <<-RUBY, __FILE__, __LINE__+1 def serializable_hash(options=nil) hash = {} @@ -134,28 +56,43 @@ def serializable_hash(options=nil) self.class.uploaders.each do |column, uploader| if (!only && !except) || (only && only.include?(column.to_s)) || (except && !except.include?(column.to_s)) - if Gem::Version.new(CarrierWave::VERSION) >= Gem::Version.new("1.0.beta") - next if _mounter(column.to_sym).uploaders.blank? - hash[column.to_s] = _mounter(column.to_sym).uploaders[0].serializable_hash - else - hash[column.to_s] = _mounter(column.to_sym).uploader.serializable_hash - end + next if _mounter(column.to_sym).uploaders.blank? + hash[column.to_s] = _mounter(column.to_sym).#{options[:multiple] ? "uploaders.map(&:serializable_hash)" : "uploaders[0].serializable_hash"} end end super(options).merge(hash) end - # Reset cached mounter on mongoid reload - def reload + # Reset cached mounter on record reload + def reload(*) @_mounters = nil super end + + # Reset cached mounter on record dup + def initialize_dup(other) + old_uploaders = _mounter(:"#{column}").uploaders + super + @_mounters[:"#{column}"] = nil + # The attribute needs to be cleared to prevent it from picked up as identifier + write_attribute(_mounter(:#{column}).serialization_column, nil) + _mounter(:"#{column}").cache(old_uploaders) + end + + def write_#{column}_identifier + return unless has_attribute?(_mounter(:#{column}).serialization_column) + super + end RUBY + end end - if Gem::Version.new(CarrierWave::VERSION) >= Gem::Version.new('1.0.beta') - def mount_uploaders(column, uploader = nil, options = {}, &block) - field (options[:mount_on] || column), type: Array, default: [] + module CarrierWavePre3 + ## + # See +CarrierWave::Mount#mount_uploader+ for documentation + # + def mount_uploader(column, uploader=nil, options={}, &block) + field options[:mount_on] || column super @@ -166,81 +103,231 @@ def mount_uploaders(column, uploader = nil, options = {}, &block) include CarrierWave::Validations::ActiveModel - validates_integrity_of column if uploader_option(column.to_sym, :validate_integrity) + validates_integrity_of column if uploader_option(column.to_sym, :validate_integrity) validates_processing_of column if uploader_option(column.to_sym, :validate_processing) - before_update :"store_previous_changes_for_#{column}" - before_save :"write_#{column}_identifier" after_save :"store_#{column}!" - after_save :"remove_previously_stored_#{column}" + before_save :"write_#{column}_identifier" after_destroy :"remove_#{column}!" + if Gem::Version.new(CarrierWave::VERSION) >= Gem::Version.new("1.0.beta") + before_update :"store_previous_changes_for_#{column}" + else + before_update :"store_previous_model_for_#{column}" + end + after_save :"remove_previously_stored_#{column}" - class_eval <<-RUBY, __FILE__, (__LINE__ + 1) - def #{column}=(new_files) + class_eval <<-RUBY, __FILE__, __LINE__+1 + def #{column}=(new_file) column = _mounter(:#{column}).serialization_column - + + # We're using _new_ and _old_ placeholder values to force Mongoid to + # recognize changes in embedded documents. Before we assign these + # values, we need to store the original file name in case we need to + # delete it when document is saved. previous_uploader_value = read_uploader(column) @_previous_uploader_value_for_#{column} = previous_uploader_value - - write_uploader(column, []) if self.persisted? && read_uploader(column).nil? - - send(:"\#{column}_will_change!") - super - end + # mongoid won't upload a new file if there was no file previously. + write_uploader(column, '_old_') if self.persisted? && read_uploader(column).nil? - def #{column}_changed? - changed_attributes.has_key?("#{column}") + send(:"\#{column}_will_change!") + super end - def remove_#{column}=(value) - if ['1', true].include?(value) + def remove_#{column}=(arg) + if ['1', true].include?(arg) column = _mounter(:#{column}).serialization_column - send(:"\#{column}_will_change!") end - super end + def remove_#{column}! + super unless respond_to?(:paranoid?) && paranoid? && flagged_for_destroy? + end + + # Overrides Mongoid's default dirty behavior to instead work more like + # ActiveRecord's. Mongoid doesn't deem an attribute as changed unless + # the new value is different than the original. Given that CarrierWave + # caches files before save, it's necessary to know that there's a + # pending change even though the attribute value itself might not + # reflect that yet. + def #{column}_changed? + changed_attributes.has_key?("#{column}") + end + # The default Mongoid attribute_will_change! method is not enough # when we want to upload a new file in an existing embedded document. # The custom version of that method forces the callbacks to be # ran and so does the upload. def #{column}_will_change! - changed_attributes["#{column}"] = ['_new_'] + changed_attributes["#{column}"] = '_new_' end - def remove_previously_stored_#{column} - before, after = @_previous_changes_for_#{column} - # Don't delete if the files had the same name - return if before.nil? && after.nil? - # Proceed to remove the file, use the original name instead of '_new_' - before = @_previous_uploader_value_for_#{column} || before - _mounter(:#{column}).remove_previous(Array.wrap(before), Array.wrap(after)) + # Since we had to use tricks with _old_ and _new_ values to properly + # track changes in embedded documents, we need to overwrite this method + # to remove the original file if it was replaced with a new one that + # had a different name. + if Gem::Version.new(CarrierWave::VERSION) >= Gem::Version.new("1.0.beta") + def remove_previously_stored_#{column} + before, after = @_previous_changes_for_#{column} + # Don't delete if the files had the same name + return if before.nil? && after.nil? + # Proceed to remove the file, use the original name instead of '_new_' + before = @_previous_uploader_value_for_#{column} || before + _mounter(:#{column}).remove_previous([before], [after]) + end end - def serializable_hash(options = nil) + # CarrierWave 1.1 references ::ActiveRecord constant directly which + # will fail in projects without ActiveRecord. We need to overwrite this + # method to avoid it. + # See https://github.com/carrierwaveuploader/carrierwave/blob/07dc4d7bd7806ab4b963cf8acbad73d97cdfe74e/lib/carrierwave/mount.rb#L189 + def store_previous_changes_for_#{column} + @_previous_changes_for_#{column} = changes[_mounter(:#{column}).serialization_column] + end + + def find_previous_model_for_#{column} + if self.embedded? + ancestors = + if self.respond_to?(:_association) # Mongoid >= 7.0.0.beta + [[ self._association.key, self._parent ]].tap { |x| x.unshift([ x.first.last._association.key, x.first.last._parent ]) while x.first.last.embedded? } + elsif self.respond_to?(:__metadata) # Mongoid >= 4.0.0.beta1 < 7.0.0.beta + [[ self.__metadata.key, self._parent ]].tap { |x| x.unshift([ x.first.last.__metadata.key, x.first.last._parent ]) while x.first.last.embedded? } + else # Mongoid < 4.0.0.beta1 + [[ self.metadata.key, self._parent ]].tap { |x| x.unshift([ x.first.last.metadata.key, x.first.last._parent ]) while x.first.last.embedded? } + end + first_parent = ancestors.first.last + reloaded_parent = first_parent.class.unscoped.find(first_parent.to_key.first) + association = ancestors.inject(reloaded_parent) { |parent,(key,ancestor)| (parent.is_a?(Array) ? parent.find(ancestor.to_key.first) : parent).send(key) } + association.is_a?(Array) ? association.find(to_key.first) : association + else + self.class.unscoped.for_ids(to_key).first + end + end + + def serializable_hash(options=nil) hash = {} - + except = options && options[:except] && Array.wrap(options[:except]).map(&:to_s) - only = options && options[:only] && Array.wrap(options[:only]).map(&:to_s) - - self.class.uploaders.each do |column, _uploader| + only = options && options[:only] && Array.wrap(options[:only]).map(&:to_s) + + self.class.uploaders.each do |column, uploader| if (!only && !except) || (only && only.include?(column.to_s)) || (except && !except.include?(column.to_s)) - next if _mounter(column.to_sym).uploaders.blank? - hash[column.to_s] = _mounter(column.to_sym).uploaders.map(&:serializable_hash) + if Gem::Version.new(CarrierWave::VERSION) >= Gem::Version.new("1.0.beta") + next if _mounter(column.to_sym).uploaders.blank? + hash[column.to_s] = _mounter(column.to_sym).uploaders[0].serializable_hash + else + hash[column.to_s] = _mounter(column.to_sym).uploader.serializable_hash + end end end - super(options).merge(hash) end - def store_previous_changes_for_#{column} - @_previous_changes_for_#{column} = changes[_mounter(:#{column}).serialization_column] + # Reset cached mounter on mongoid reload + def reload + @_mounters = nil + super end RUBY end + + if Gem::Version.new(CarrierWave::VERSION) >= Gem::Version.new('1.0.beta') + def mount_uploaders(column, uploader = nil, options = {}, &block) + field (options[:mount_on] || column), type: Array, default: [] + + super + + alias_method :read_uploader, :read_attribute + alias_method :write_uploader, :write_attribute + public :read_uploader + public :write_uploader + + include CarrierWave::Validations::ActiveModel + + validates_integrity_of column if uploader_option(column.to_sym, :validate_integrity) + validates_processing_of column if uploader_option(column.to_sym, :validate_processing) + + before_update :"store_previous_changes_for_#{column}" + before_save :"write_#{column}_identifier" + after_save :"store_#{column}!" + after_save :"remove_previously_stored_#{column}" + after_destroy :"remove_#{column}!" + + class_eval <<-RUBY, __FILE__, (__LINE__ + 1) + def #{column}=(new_files) + column = _mounter(:#{column}).serialization_column + + previous_uploader_value = read_uploader(column) + @_previous_uploader_value_for_#{column} = previous_uploader_value + + write_uploader(column, []) if self.persisted? && read_uploader(column).nil? + + send(:"\#{column}_will_change!") + + super + end + + def #{column}_changed? + changed_attributes.has_key?("#{column}") + end + + def remove_#{column}=(value) + if ['1', true].include?(value) + column = _mounter(:#{column}).serialization_column + + send(:"\#{column}_will_change!") + end + + super + end + + # The default Mongoid attribute_will_change! method is not enough + # when we want to upload a new file in an existing embedded document. + # The custom version of that method forces the callbacks to be + # ran and so does the upload. + def #{column}_will_change! + changed_attributes["#{column}"] = ['_new_'] + end + + def remove_previously_stored_#{column} + before, after = @_previous_changes_for_#{column} + # Don't delete if the files had the same name + return if before.nil? && after.nil? + # Proceed to remove the file, use the original name instead of '_new_' + before = @_previous_uploader_value_for_#{column} || before + _mounter(:#{column}).remove_previous(Array.wrap(before), Array.wrap(after)) + end + + def serializable_hash(options = nil) + hash = {} + + except = options && options[:except] && Array.wrap(options[:except]).map(&:to_s) + only = options && options[:only] && Array.wrap(options[:only]).map(&:to_s) + + self.class.uploaders.each do |column, _uploader| + if (!only && !except) || (only && only.include?(column.to_s)) || (except && !except.include?(column.to_s)) + next if _mounter(column.to_sym).uploaders.blank? + hash[column.to_s] = _mounter(column.to_sym).uploaders.map(&:serializable_hash) + end + end + + super(options).merge(hash) + end + + def store_previous_changes_for_#{column} + @_previous_changes_for_#{column} = changes[_mounter(:#{column}).serialization_column] + end + RUBY + end + end + end + + if Gem::Version.new(CarrierWave::VERSION) >= Gem::Version.new("3.0.beta") + include CarrierWave3 + else + include CarrierWavePre3 end end # Mongoid end # CarrierWave @@ -255,4 +342,24 @@ class CarrierWave::Uploader::Base end end +if Gem::Version.new("3.0.beta") <= Gem::Version.new(CarrierWave::VERSION) && + Gem::Version.new(CarrierWave::VERSION) < Gem::Version.new("3.1.1") + # Monkey patch to work around https://github.com/carrierwaveuploader/carrierwave/issues/2770 + CarrierWave::SanitizedFile.prepend Module.new { + def read(*args) + if args.empty? + super + else + if is_path? + @file = File.open(path, "rb") + elsif @file.is_a?(CarrierWave::Uploader::Base) + @file = StringIO.new(@file.read) + end + + @file.read(*args) + end + end + } +end + Mongoid::Document::ClassMethods.send(:include, CarrierWave::Mongoid) diff --git a/spec/carrierwave/mongoid/mount_uploaders_spec.rb b/spec/carrierwave/mongoid/mount_uploaders_spec.rb index 5694e1e..67d5dd8 100644 --- a/spec/carrierwave/mongoid/mount_uploaders_spec.rb +++ b/spec/carrierwave/mongoid/mount_uploaders_spec.rb @@ -382,15 +382,20 @@ def munge before do model_class.create!(images: files) - end - - it 'replaced it by a file with the same name' do record.update!(images: [stub_file('test.jpeg')]) - record.reload + end - expect(record[:images]).to match_array(['test.jpeg']) - expect(record.images_identifiers).to match_array(['test.jpeg']) + if Gem::Version.new(CarrierWave::VERSION) >= Gem::Version.new("3.0.beta") + it "performs deduplication" do + expect(record[:images]).to match_array(['test(2).jpeg']) + expect(record.images_identifiers).to match_array(['test(2).jpeg']) + end + else + it 'replaced it by a file with the same name' do + expect(record[:images]).to match_array(['test.jpeg']) + expect(record.images_identifiers).to match_array(['test.jpeg']) + end end end diff --git a/spec/mongoid_spec.rb b/spec/mongoid_spec.rb index 114a884..2fb1aed 100644 --- a/spec/mongoid_spec.rb +++ b/spec/mongoid_spec.rb @@ -340,14 +340,21 @@ def extension_white_list @doc.image = stub_file('test.jpeg') @doc.save @doc.reload - end - - it "replaced it by a file with the same name" do @doc.image = stub_file('test.jpeg') @doc.save @doc.reload - expect(@doc[:image]).to eq 'test.jpeg' - expect(@doc.image_identifier).to eq 'test.jpeg' + end + + if Gem::Version.new(CarrierWave::VERSION) >= Gem::Version.new("3.0.beta") + it "performs deduplication" do + expect(@doc[:image]).to eq 'test(2).jpeg' + expect(@doc.image_identifier).to eq 'test(2).jpeg' + end + else + it "replaced it by a file with the same name" do + expect(@doc[:image]).to eq 'test.jpeg' + expect(@doc.image_identifier).to eq 'test.jpeg' + end end end