require 'agnostic/duplicate/version' module Agnostic # Duplicate objects are provided with an additional method `duplicate` that # extends the method `dup` functionality. # # ## When to use # # The advantage of using Duplicate module reside in support for fields that # are not duplicated by default for any reason. Example: when using Rails # `dup` implementation doesn't copy attributes of model that return an # ActiveRecord::Relation, it is supossed the developer to choose his strategy. # # ## Usage # # When using `Duplicate` you specify a list of attributes that you want to be # copied additionaly to the object returned by `dup`. Though if `dup` returns # a value for an attribute and you mark that attribute as "duplicable" then # the value of the attribute will be overwritten with the value provided by # `duplicate` call. # # Example: # # ```ruby # class Story < ActiveRecord::Base # include Duplicate # # ... # attr_duplicable :seo_element, :category, :properties # # ... # attr_accessible :title # # ... # has_one :seo_element, as: :metadatable # has_one :category, through: :categorisation, source: :category # has_many :properties, :images, :headlines # # ... # end # ``` # # When using `duplicable` over any attribute, it verifies if the current value # value implements `Duplicate`. In that case it returns the result of calling # to `duplicate` on that object. If the attribute doesn't implement # `Duplicate` it is returned the `dup` value. # # If the `duplicable` attribute is iterable then it is returned an array where # every element of the collection is duplicated following the flow defined # previously. # # Also it is possible to provide **shallow copies** of attribute values, # modifying the default behaviour. In that case, just make use of the # `strategy` option. # # ```ruby # attr_duplicable :images, strategy: :shallow_copy # ``` # # It is given support for custom behaviour after duplication process. In that # case it is only required to implement the method `hook_after_duplicate!` # # Extending previous example: # # ```ruby # def hook_after_duplicate!(duplicate) # duplicate.headlines = self.headlines.not_orphans.collect(&:dup) # duplicate.images.each { |img| img.attachable = duplicate } # end # ``` # # **ATENTION:** Observe that `model` passed as parameter is in fact the # duplicated instance that it is going to be returned # # ## Configuration options # # If the only attribute values you want to be duplicated are the ones you have # specified through the `attr_duplicable` method, and though removing the # additional fields duplicated because of the init call to `dup`, then you can # set this configuration through `duplicable_config` method: # # ```ruby # class Image < ActiveRecord::Base # include Duplicate # duplicable_config new_instance: true # # ... # attr_duplicable :images # # ... # end # ``` module Duplicate def self.included(base) base.extend(ClassMethods) base.instance_variable_set '@duplicable_changesets', [] base.instance_variable_set '@duplicable_options', {} end # Duplicates the object # @return [Duplicate] the new instance object def duplicate dup_template.tap do |model| apply_changesets!(model) hook_after_duplicate!(model) if respond_to? :hook_after_duplicate! end end private # Applies to model the duplicable changesets defined in class definition # @param model [Duplicate] the duplicated new instance object def apply_changesets!(model) self.class.duplicable_changesets.each do |changeset| changeset.apply(self, model) end end # Contains all kinds of changesets that can be applied to a duplicable # object module ChangeSet # Base class for all changesets. Subclasses should implement method # `apply` (see #apply) class Base attr_reader :attributes def initialize(attributes) @attributes = attributes end end # Defines a changeset where a deep copy wants to be applied to all # attributes class DeepCopy < Base # Applies changes needed on the duplicated new instance object # @param parent [Duplicate] the original object to be duplicated # @param model [Duplicate] the duplicated new instance object def apply(parent, model) attributes.each do |attribute| setter_method = "#{attribute}=" if model.respond_to?(setter_method) model.send(setter_method, dup_attribute(parent, attribute)) else fail "Invalid duplicable attribute '#{attribute}'" end end end private # @param parent [Duplicate] the original object to be duplicated # @param attribute [Symbol] the attribute to be duplicated # @return from a duplicable object the duplicated value for the # attribute specified def dup_attribute(parent, attribute) value = parent.send(attribute) klass = self.class if value && value.respond_to?(:collect) value.map { |item| klass.dup_item(item) } else value && klass.dup_item(value) end end # Duplicates the object passed as parameter # @param item [Object] object to be duplicated # @return [Object] the duplicated new instance object def self.dup_item(item) if item.respond_to? :duplicate item.duplicate else item.dup end rescue item end end # Defines a changeset where a deep copy wants to be applied to all # attributes. # # Though if the field value is a memory address it copies the memory # address, and if the field value is a primitive type it copies the value # of the primitive type. class ShallowCopy < Base # Applies changes needed on the duplicated new instance object # @param parent [Duplicate] the original object to be duplicated # @param model [Duplicate] the duplicated new instance object def apply(parent, model) attributes.each do |attribute| model.send("#{attribute}=", parent.send(attribute)) end end end end private # @return [Duplicate] a new instance object based on global duplicable # configuration def dup_template klass = self.class if klass.duplicable_option? :new_instance klass.new else dup end end # Methods added to classes including Duplicate module module ClassMethods attr_accessor :duplicable_changesets, :duplicable_options # Adds a new duplicable changeset for the class. # # By default created changesets apply a deep copy strategy over the # attributes specified. If you want to set a shallow copy strategy then # you can add the option `strategy: :shallow_copy` # # @param *args [Array] a list of attribute names # @param options [Hash] options specific for the changeset def attr_duplicable(*args) @changeset_options = {} @changeset_options = args.pop if args.last.is_a? Hash duplicable_changesets << changeset_class.new(args) end # Sets global options for applying changesets # # ## Options available: # - `new_instance`: if `true` the duplicated instance is created calling # in first place `new` method over the class. if `false` the duplicated # instance is created calling to `dup` method over the instance object. # # @param options [Hash] def duplicable_config(options) if options.is_a? Hash @duplicable_options.merge! options keep_valid_options else fail ArgumentError, 'Invalid options configuration' end end # @param option [Symbol] global option for duplication # @return [Boolean] the boolean value expressing if the option is # activated def duplicable_option?(option) @duplicable_options ||= {} @duplicable_options[option] end private # Remove unknown options for applying changesets def keep_valid_options @duplicable_options.keep_if { |key, _| [:new_instance].include? key } end # @return [ChangeSet::Object] based on the strategy of duplication to be # applied over the attributes def changeset_class strategy = @changeset_options[:strategy] || :deep_copy class_name = strategy.to_s.split('_').map(&:capitalize).join Duplicate.const_get('ChangeSet').const_get("#{class_name}") end end end end