├── .rspec ├── .cane ├── lib ├── amoeba │ ├── version.rb │ ├── macros.rb │ ├── macros │ │ ├── has_one.rb │ │ ├── has_and_belongs_to_many.rb │ │ ├── base.rb │ │ └── has_many.rb │ ├── class_methods.rb │ ├── instance_methods.rb │ ├── config.rb │ └── cloner.rb └── amoeba.rb ├── .gitignore ├── Rakefile ├── defaults.reek ├── Gemfile ├── .rubocop.yml ├── gemfiles ├── activerecord_6.1.gemfile ├── activerecord_7.0.gemfile ├── activerecord_7.1.gemfile ├── jruby_activerecord_7.0.gemfile ├── activerecord_head.gemfile └── jruby_activerecord_head.gemfile ├── .github └── workflows │ ├── rubocop.yml │ ├── activerecord_head.yml │ ├── ruby_head.yml │ ├── jruby.yml │ ├── ruby_30.yml │ ├── ruby_31.yml │ ├── ruby_32.yml │ └── ruby_33.yml ├── docs ├── test_refactor.md └── contributing.md ├── Appraisals ├── spec ├── spec_helper.rb ├── support │ ├── data.rb │ ├── schema.rb │ └── models.rb └── lib │ └── amoeba_spec.rb ├── CHANGELOG.md ├── LICENSE.md ├── amoeba.gemspec ├── .rubocop_todo.yml └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /.cane: -------------------------------------------------------------------------------- 1 | --no-doc 2 | --style-measure 99 3 | --abc-max 5 4 | --style-exclude spec/**/* 5 | -------------------------------------------------------------------------------- /lib/amoeba/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Amoeba 4 | VERSION = '3.3.0' 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile*.lock 4 | pkg/* 5 | spec/test.sqlite3 6 | coverage 7 | .idea 8 | gemfiles/*.lock 9 | .ruby-version 10 | tmp/**/* 11 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /defaults.reek: -------------------------------------------------------------------------------- 1 | --- 2 | NestedIterators: 3 | max_allowed_nesting: 2 4 | UtilityFunction: 5 | enabled: false 6 | IrresponsibleModule: 7 | enabled: false 8 | DuplicateMethodCall: 9 | max_calls: 5 10 | FeatureEnvy: 11 | enabled: false 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | gemspec 5 | 6 | group :development, :test do 7 | gem 'rake' 8 | gem 'simplecov', '~> 0.21.2' 9 | gem 'simplecov-lcov', '~> 0.8.0' 10 | end 11 | 12 | group :local_development do 13 | gem 'appraisal' 14 | gem 'pry' 15 | end 16 | -------------------------------------------------------------------------------- /lib/amoeba/macros.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Amoeba 4 | module Macros 5 | extend self 6 | def list 7 | @list ||= {} 8 | end 9 | 10 | def add(klass) 11 | @list ||= {} 12 | key = klass.name.demodulize.underscore.to_sym 13 | @list[key] = klass 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | - rubocop-rake 4 | 5 | inherit_from: .rubocop_todo.yml 6 | 7 | AllCops: 8 | TargetRubyVersion: 2.7 9 | NewCops: enable 10 | Layout/LineLength: 11 | Max: 120 12 | Naming/FileName: 13 | Enabled: false 14 | Style/ModuleFunction: 15 | Enabled: false 16 | Style/Encoding: 17 | Enabled: false 18 | Style/Documentation: 19 | Enabled: false 20 | Metrics/MethodLength: 21 | Max: 15 22 | -------------------------------------------------------------------------------- /gemfiles/activerecord_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activerecord', '~> 6.1.0' 8 | 9 | group :development, :test do 10 | gem 'rake' 11 | gem 'simplecov', '~> 0.21.2' 12 | gem 'simplecov-lcov', '~> 0.8.0' 13 | end 14 | 15 | group :local_development do 16 | gem 'appraisal' 17 | gem 'pry' 18 | end 19 | 20 | gemspec path: '../' 21 | -------------------------------------------------------------------------------- /gemfiles/activerecord_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activerecord', '~> 7.0.0' 8 | 9 | group :development, :test do 10 | gem 'rake' 11 | gem 'simplecov', '~> 0.21.2' 12 | gem 'simplecov-lcov', '~> 0.8.0' 13 | end 14 | 15 | group :local_development do 16 | gem 'appraisal' 17 | gem 'pry' 18 | end 19 | 20 | gemspec path: '../' 21 | -------------------------------------------------------------------------------- /gemfiles/activerecord_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activerecord', '~> 7.1.0' 8 | 9 | group :development, :test do 10 | gem 'rake' 11 | gem 'simplecov', '~> 0.21.2' 12 | gem 'simplecov-lcov', '~> 0.8.0' 13 | end 14 | 15 | group :local_development do 16 | gem 'appraisal' 17 | gem 'pry' 18 | end 19 | 20 | gemspec path: '../' 21 | -------------------------------------------------------------------------------- /gemfiles/jruby_activerecord_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'activerecord', '~> 7.0.0' 8 | 9 | group :development, :test do 10 | gem 'rake' 11 | gem 'simplecov', '~> 0.21.2' 12 | gem 'simplecov-lcov', '~> 0.8.0' 13 | end 14 | 15 | group :local_development do 16 | gem 'appraisal' 17 | gem 'pry' 18 | end 19 | 20 | gemspec path: '../' 21 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: Rubocop 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Ruby 15 | uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: 3.0 18 | bundler-cache: true 19 | - name: Run Rubocop 20 | run: bundle exec rubocop -------------------------------------------------------------------------------- /gemfiles/activerecord_head.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | git 'https://github.com/rails/rails.git', branch: 'main' do 8 | gem 'activerecord' 9 | end 10 | 11 | group :development, :test do 12 | gem 'rake' 13 | gem 'simplecov', '~> 0.21.2' 14 | gem 'simplecov-lcov', '~> 0.8.0' 15 | end 16 | 17 | group :local_development do 18 | gem 'appraisal' 19 | gem 'pry' 20 | end 21 | 22 | gemspec path: '../' 23 | -------------------------------------------------------------------------------- /lib/amoeba.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record' 4 | require 'active_support/all' 5 | require 'amoeba/version' 6 | require 'amoeba/config' 7 | require 'amoeba/macros' 8 | require 'amoeba/macros/base' 9 | require 'amoeba/macros/has_many' 10 | require 'amoeba/macros/has_one' 11 | require 'amoeba/macros/has_and_belongs_to_many' 12 | require 'amoeba/cloner' 13 | require 'amoeba/class_methods' 14 | require 'amoeba/instance_methods' 15 | 16 | module Amoeba 17 | end 18 | 19 | ActiveSupport.on_load :active_record do 20 | extend Amoeba::ClassMethods 21 | include Amoeba::InstanceMethods 22 | end 23 | -------------------------------------------------------------------------------- /lib/amoeba/macros/has_one.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Amoeba 4 | module Macros 5 | class HasOne < ::Amoeba::Macros::Base 6 | def follow(relation_name, association) 7 | return if association.is_a?(::ActiveRecord::Reflection::ThroughReflection) 8 | 9 | old_obj = @old_object.__send__(relation_name) 10 | return unless old_obj 11 | 12 | copy_of_obj = old_obj.amoeba_dup(@options) 13 | copy_of_obj[:"#{association.foreign_key}"] = nil 14 | relation_name = remapped_relation_name(relation_name) 15 | @new_object.__send__(:"#{relation_name}=", copy_of_obj) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.github/workflows/activerecord_head.yml: -------------------------------------------------------------------------------- 1 | name: Active Record head 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby_version: [ 3.0, 3.1, 3.2, 3.3, head ] 15 | env: 16 | BUNDLE_GEMFILE: gemfiles/activerecord_head.gemfile 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby_version }} 23 | bundler-cache: true 24 | - name: Run tests 25 | # Allow tests to run on Active Record head without failing the pipeline 26 | run: bundle exec rspec || true 27 | -------------------------------------------------------------------------------- /.github/workflows/ruby_head.yml: -------------------------------------------------------------------------------- 1 | name: Ruby head 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | continue-on-error: true 13 | strategy: 14 | matrix: 15 | gemfile: [ activerecord_6.1, activerecord_7.0, activerecord_7.1 ] 16 | env: 17 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: head 24 | bundler-cache: true 25 | - name: Run tests 26 | # Allow tests to run on Ruby head without failing the pipeline 27 | run: bundle exec rspec || true 28 | -------------------------------------------------------------------------------- /gemfiles/jruby_activerecord_head.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by Appraisal 4 | 5 | source 'https://rubygems.org' 6 | 7 | git 'https://github.com/rails/rails.git', branch: 'main' do 8 | gem 'activerecord' 9 | end 10 | 11 | group :development, :test do 12 | git 'https://github.com/jruby/activerecord-jdbc-adapter' do 13 | gem 'activerecord-jdbc-adapter' 14 | gem 'activerecord-jdbcsqlite3-adapter', 15 | glob: 'activerecord-jdbcsqlite3-adapter/activerecord-jdbcsqlite3-adapter.gemspec' 16 | end 17 | 18 | gem 'rake' 19 | gem 'simplecov', '~> 0.21.2' 20 | gem 'simplecov-lcov', '~> 0.8.0' 21 | end 22 | 23 | group :local_development do 24 | gem 'appraisal' 25 | gem 'pry' 26 | end 27 | 28 | gemspec path: '../' 29 | -------------------------------------------------------------------------------- /lib/amoeba/class_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Amoeba 4 | module ClassMethods 5 | def amoeba(&block) 6 | @config_block ||= block if block_given? 7 | 8 | @config ||= Amoeba::Config.new(self) 9 | @config.instance_eval(&block) if block_given? 10 | @config 11 | end 12 | 13 | def fresh_amoeba(&block) 14 | @config_block = block if block_given? 15 | 16 | @config = Amoeba::Config.new(self) 17 | @config.instance_eval(&block) if block_given? 18 | @config 19 | end 20 | 21 | def reset_amoeba(&block) 22 | @config_block = block if block_given? 23 | @config = Amoeba::Config.new(self) 24 | end 25 | 26 | def amoeba_block 27 | @config_block 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /.github/workflows/jruby.yml: -------------------------------------------------------------------------------- 1 | name: JRuby 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | gemfile: [ jruby_activerecord_7.0 ] 15 | env: 16 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 17 | JRUBY_OPTS: --debug 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: jruby 24 | bundler-cache: true 25 | - name: Run tests 26 | run: bundle exec rspec 27 | - name: Coveralls 28 | uses: coverallsapp/github-action@master 29 | with: 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /lib/amoeba/macros/has_and_belongs_to_many.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Amoeba 4 | module Macros 5 | class HasAndBelongsToMany < ::Amoeba::Macros::Base 6 | def follow(relation_name, _association) 7 | clone = @cloner.amoeba.clones.include?(relation_name.to_sym) 8 | @old_object.__send__(relation_name).each do |old_obj| 9 | fill_relation(relation_name, old_obj, clone) 10 | end 11 | end 12 | 13 | def fill_relation(relation_name, old_obj, clone) 14 | # associate this new child to the new parent object 15 | old_obj = old_obj.amoeba_dup if clone 16 | relation_name = remapped_relation_name(relation_name) 17 | @new_object.__send__(relation_name) << old_obj 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/amoeba/macros/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Amoeba 4 | module Macros 5 | class Base 6 | def initialize(cloner) 7 | @cloner = cloner 8 | @old_object = cloner.old_object 9 | @new_object = cloner.new_object 10 | end 11 | 12 | def follow(_relation_name, _association) 13 | raise "#{self.class.name} doesn't implement `follow`!" 14 | end 15 | 16 | class << self 17 | def inherited(klass) 18 | ::Amoeba::Macros.add(klass) 19 | end 20 | end 21 | 22 | def remapped_relation_name(name) 23 | return name unless @cloner.amoeba.remap_method 24 | 25 | @old_object.__send__(@cloner.amoeba.remap_method, name.to_sym) || name 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.github/workflows/ruby_30.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 3.0 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | gemfile: [ activerecord_6.1, activerecord_7.0, activerecord_7.1 ] 16 | env: 17 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: 3.0 24 | bundler-cache: true 25 | - name: Run tests 26 | run: bundle exec rspec 27 | - name: Coveralls 28 | uses: coverallsapp/github-action@master 29 | with: 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/ruby_31.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 3.1 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | gemfile: [ activerecord_6.1, activerecord_7.0, activerecord_7.1 ] 16 | env: 17 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: 3.1 24 | bundler-cache: true 25 | - name: Run tests 26 | run: bundle exec rspec 27 | - name: Coveralls 28 | uses: coverallsapp/github-action@master 29 | with: 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/ruby_32.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 3.2 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | gemfile: [ activerecord_6.1, activerecord_7.0, activerecord_7.1 ] 16 | env: 17 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: 3.2 24 | bundler-cache: true 25 | - name: Run tests 26 | run: bundle exec rspec 27 | - name: Coveralls 28 | uses: coverallsapp/github-action@master 29 | with: 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/ruby_33.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 3.3 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | gemfile: [ activerecord_6.1, activerecord_7.0, activerecord_7.1 ] 16 | env: 17 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: 3.3 24 | bundler-cache: true 25 | - name: Run tests 26 | run: bundle exec rspec 27 | - name: Coveralls 28 | uses: coverallsapp/github-action@master 29 | with: 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /docs/test_refactor.md: -------------------------------------------------------------------------------- 1 | # Test refactor 2 | 3 | ## Motivation 4 | 5 | This gem has been unmaintained for some time but is still used. This could 6 | cause problems if it becomes incompatible with new versions of Active Record. 7 | Additionally, there are lots of issues that have been raised and there could be 8 | other features of Active Record that should be supported. However, the tests 9 | not easy to understand as they generally comprise a large setup of data 10 | followed by multiple expectations with no indication of their purpose. A better 11 | structure for the tests would allow for making changes with more confidence. 12 | 13 | ## Plan 14 | 15 | All of the tests are currently in `spec/lib/amoeba_spec.rb`. New tests will be 16 | created to mimic the modules so, for example; 17 | 18 | * `spec/lib/amoeba/cloner_spec.rb` to test the `Amoeba::Cloner` class. 19 | * `spec/lib/amoeba/macros/has_many_spec.rb` to test the 20 | `Amoeba::Macros::HasMany` module. 21 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'activerecord-6.1' do 4 | gem 'activerecord', '~> 6.1.0' 5 | end 6 | 7 | appraise 'activerecord-7.0' do 8 | gem 'activerecord', '~> 7.0.0' 9 | end 10 | 11 | appraise 'activerecord-7.1' do 12 | gem 'activerecord', '~> 7.1.0' 13 | end 14 | 15 | appraise 'jruby-activerecord-7.0' do 16 | gem 'activerecord', '~> 7.0.0' 17 | end 18 | 19 | appraise 'activerecord-head' do 20 | git 'https://github.com/rails/rails.git', branch: 'main' do 21 | gem 'activerecord' 22 | end 23 | end 24 | 25 | appraise 'jruby-activerecord-head' do 26 | git 'https://github.com/rails/rails.git', branch: 'main' do 27 | gem 'activerecord' 28 | end 29 | group :development, :test do 30 | git 'https://github.com/jruby/activerecord-jdbc-adapter' do 31 | gem 'activerecord-jdbc-adapter' 32 | gem 'activerecord-jdbcsqlite3-adapter', 33 | glob: 'activerecord-jdbcsqlite3-adapter/activerecord-jdbcsqlite3-adapter.gemspec' 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | 5 | SimpleCov.start do 6 | add_filter 'spec' 7 | minimum_coverage(76) 8 | 9 | if ENV['CI'] 10 | require 'simplecov-lcov' 11 | 12 | SimpleCov::Formatter::LcovFormatter.config do |c| 13 | c.report_with_single_file = true 14 | c.single_report_path = 'coverage/lcov.info' 15 | end 16 | 17 | formatter SimpleCov::Formatter::LcovFormatter 18 | end 19 | end 20 | 21 | require 'active_record' 22 | require 'amoeba' 23 | 24 | adapter = if defined?(JRuby) 25 | require 'activerecord-jdbcsqlite3-adapter' 26 | 'jdbcsqlite3' 27 | else 28 | require 'sqlite3' 29 | 'sqlite3' 30 | end 31 | 32 | ActiveRecord::Base.establish_connection(adapter: adapter, database: ':memory:') 33 | 34 | ::RSpec.configure do |config| 35 | config.order = :defined 36 | end 37 | 38 | load File.dirname(__FILE__) + '/support/schema.rb' 39 | load File.dirname(__FILE__) + '/support/models.rb' 40 | -------------------------------------------------------------------------------- /lib/amoeba/instance_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Amoeba 4 | module InstanceMethods 5 | def _parent_amoeba 6 | if _first_superclass_with_amoeba.respond_to?(:amoeba) 7 | _first_superclass_with_amoeba.amoeba 8 | else 9 | false 10 | end 11 | end 12 | 13 | def _first_superclass_with_amoeba 14 | return @_first_superclass_with_amoeba unless @_first_superclass_with_amoeba.nil? 15 | 16 | klass = self.class 17 | while klass.superclass < ::ActiveRecord::Base 18 | klass = klass.superclass 19 | break if klass.respond_to?(:amoeba) && klass.amoeba.enabled 20 | end 21 | @_first_superclass_with_amoeba = klass 22 | end 23 | 24 | def _amoeba_settings 25 | self.class.amoeba_block 26 | end 27 | 28 | def _parent_amoeba_settings 29 | if _first_superclass_with_amoeba.respond_to?(:amoeba_block) 30 | _first_superclass_with_amoeba.amoeba_block 31 | else 32 | false 33 | end 34 | end 35 | 36 | def amoeba_dup(options = {}) 37 | ::Amoeba::Cloner.new(self, options).run 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Unreleased 2 | 3 | * Drop support for Rails 5.2, Ruby 2.5 and 2.6. [https://github.com/amoeba-rb/amoeba/pull/120] 4 | * Notes on contributing. Github Actions for automate tests. [https://github.com/amoeba-rb/amoeba/pull/124] 5 | * Fix tests for Active Record after 7.1. [https://github.com/amoeba-rb/amoeba/pull/127] 6 | 7 | ### 3.3.0 8 | 9 | * Move test pipelines from Travis to Github Actions. 10 | * `include_field` and `exclude_field` configuration options have been removed. 11 | These had been marked as deprecated in version 2 and replaced by 12 | `include_association` and `exclude_association`. 13 | * Official support dropped for Rails 5.1 and earlier. Test pipelines now run 14 | for Rails 5.2 up to 7.0 as well as the current development head. 15 | * Official support dropped for Ruby 2.4 and earlier. Test pipelines now run for 16 | Ruby 2.5 up to 3.2 as well as the current development head. 17 | * Ambiguous 'BSD' license replaced with 'BSD 2-Claus "Simplified" License'. 18 | * Fix copy-and-paste mistake in documenation. Thanks @budu. 19 | * Use lazy load hooks to extend ActiveRecord::Base. This is to ensure 20 | compatibility with Factory Bot Rails 6.4.0. Thanks @tagliala. 21 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to Amoeba 2 | 3 | Contributations to this project are welcome. The current needs are; 4 | 5 | * More structured documentation 6 | * [Refactoring tests](test_refactor.md) 7 | * Identifying and prioritising features that could be implemented from the long list of issues 8 | * Reviewing of pull requests 9 | 10 | ## Some notes regarding pull requests 11 | 12 | When making code changes please include unit tests for any new code and ensure that all tests pass for supported versions of Ruby and Active Record. These tests are run automatically. 13 | 14 | Note that the tests are also run automatically for the current development branches of Ruby and Active Record. It is not necessary for all tests to pass in these cases and they are set up to always report as a success to avoid causing the whole pipeline to appear as failed[^1]. However; 15 | 16 | * Please try at least for new tests in the PR to pass unless there is an identifiable issue in the development version of either Ruby or Active Record. 17 | * For any new failures you see please consider raising an issue to have it fixed. 18 | 19 | [^1]: See [this issue](https://github.com/actions/runner/issues/2347) and [this discussion](https://github.com/orgs/community/discussions/15452) for a long-running discussion on the missing "allow failure" feature in Github Actions. -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | [The 2-Clause BSD License](http://www.opensource.org/licenses/bsd-license.php) 2 | 3 | Copyright (c) 2012, Vaughn Draughon 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /lib/amoeba/macros/has_many.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Amoeba 4 | module Macros 5 | class HasMany < ::Amoeba::Macros::Base 6 | def follow(relation_name, association) 7 | if @cloner.amoeba.clones.include?(relation_name.to_sym) 8 | follow_with_clone(relation_name) 9 | else 10 | follow_without_clone(relation_name, association) 11 | end 12 | end 13 | 14 | def follow_with_clone(relation_name) 15 | # This is a M:M "has many through" where we 16 | # actually copy and reassociate the new children 17 | # rather than only maintaining the associations 18 | @old_object.__send__(relation_name).each do |old_obj| 19 | relation_name = remapped_relation_name(relation_name) 20 | # associate this new child to the new parent object 21 | @new_object.__send__(relation_name) << old_obj.amoeba_dup 22 | end 23 | end 24 | 25 | def follow_without_clone(relation_name, association) 26 | # This is a regular 1:M "has many" 27 | # 28 | # copying the children of the regular has many will 29 | # effectively do what is desired anyway, the through 30 | # association is really just for convenience usage 31 | # on the model 32 | return if association.is_a?(ActiveRecord::Reflection::ThroughReflection) 33 | 34 | @old_object.__send__(relation_name).each do |old_obj| 35 | copy_of_obj = old_obj.amoeba_dup(@options) 36 | copy_of_obj[:"#{association.foreign_key}"] = nil 37 | relation_name = remapped_relation_name(relation_name) 38 | # associate this new child to the new parent object 39 | @new_object.__send__(relation_name) << copy_of_obj 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /amoeba.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH.push File.expand_path('lib', __dir__) 5 | require 'amoeba/version' 6 | 7 | Gem::Specification.new do |s| 8 | s.name = 'amoeba' 9 | s.version = Amoeba::VERSION 10 | s.authors = ['Vaughn Draughon', 'Oleksandr Simonov'] 11 | s.email = 'alex@simonov.me' 12 | s.homepage = 'http://github.com/amoeba-rb/amoeba' 13 | s.license = 'BSD-2-Clause' 14 | s.summary = 'Easy copying of rails models and their child associations.' 15 | s.required_ruby_version = '>= 2.5' 16 | 17 | s.description = <<~DESCRIPTION 18 | An extension to ActiveRecord to allow the duplication method to also copy associated children, with recursive support for nested of grandchildren. The behavior is controllable with a simple DSL both on your rails models and on the fly, i.e. per instance. Numerous configuration styles and preprocessing directives are included for power and flexibility. Supports preprocessing of field values to prepend strings such as "Copy of ", to nullify or process field values with regular expressions. Supports most association types including has_one :through and has_many :through. 19 | 20 | Tags: copy child associations, copy nested children, copy associated child records, nested copy, copy associations, copy relations, copy relationships, duplicate associations, duplicate associated records, duplicate child records, duplicate children, copy all, duplicate all, clone child associations, clone nested children, clone associated child records, nested clone, clone associations, clone relations, clone relationships, cloning child associations, cloning nested children, cloning associated child records, deep_cloning, nested cloning, cloning associations, cloning relations, cloning relationships, cloning child associations, cloning nested children, cloning associated child records, nested cloning, cloning associations, cloning relations, cloning relationships, cloning child associations, cloning nested children, cloning associated child records, deep_cloning, nested cloning, cloning associations, cloning relations, cloning relationships, duplicate child associations, duplicate nested children, duplicate associated child records, nested duplicate, duplicate associations, duplicate relations, duplicate relationships, duplicate child associations, duplicate nested children, duplicate associated child records, deep_duplicate, nested duplicate, duplicate associations, duplicate relations, duplicate relationships, deep_copy, deep_clone, deep_cloning, deep clone, deep cloning, has_one, has_many, has_and_belongs_to_many 21 | DESCRIPTION 22 | 23 | s.files = `git ls-files`.split("\n") 24 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 25 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 26 | s.require_paths = ['lib'] 27 | 28 | # specify any dependencies here; for example: 29 | s.add_development_dependency 'rspec', '~> 3.13.0' 30 | s.add_development_dependency 'rubocop', '= 1.62.1' 31 | s.add_development_dependency 'rubocop-rake', '~> 0.6.0' 32 | s.add_development_dependency 'rubocop-rspec', '~> 2.27.1' 33 | 34 | if RUBY_PLATFORM == 'java' 35 | s.add_development_dependency 'activerecord-jdbc-adapter', '= 70.1' 36 | s.add_development_dependency 'activerecord-jdbcsqlite3-adapter', '= 70.1' 37 | else 38 | s.add_development_dependency 'sqlite3', '~> 1.6.0' 39 | end 40 | 41 | s.add_dependency 'activerecord', '>= 6.1.0' 42 | end 43 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2023-10-02 15:42:47 UTC using RuboCop version 1.56.3. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | # This cop supports safe autocorrection (--autocorrect). 11 | # Configuration parameters: Severity, Include. 12 | # Include: **/*.gemspec 13 | Gemspec/DeprecatedAttributeAssignment: 14 | Exclude: 15 | - 'amoeba.gemspec' 16 | 17 | # Offense count: 7 18 | # Configuration parameters: EnforcedStyle, AllowedGems, Include. 19 | # SupportedStyles: Gemfile, gems.rb, gemspec 20 | # Include: **/*.gemspec, **/Gemfile, **/gems.rb 21 | Gemspec/DevelopmentDependencies: 22 | Exclude: 23 | - 'amoeba.gemspec' 24 | 25 | # Offense count: 1 26 | # This cop supports safe autocorrection (--autocorrect). 27 | # Configuration parameters: Severity, Include. 28 | # Include: **/*.gemspec 29 | Gemspec/RequireMFA: 30 | Exclude: 31 | - 'amoeba.gemspec' 32 | 33 | # Offense count: 1 34 | # Configuration parameters: Severity, Include. 35 | # Include: **/*.gemspec 36 | Gemspec/RequiredRubyVersion: 37 | Exclude: 38 | - 'amoeba.gemspec' 39 | 40 | # Offense count: 1 41 | # Configuration parameters: AllowedParentClasses. 42 | Lint/MissingSuper: 43 | Exclude: 44 | - 'lib/amoeba/macros/base.rb' 45 | 46 | # Offense count: 17 47 | # This cop supports unsafe autocorrection (--autocorrect-all). 48 | Lint/UselessAssignment: 49 | Exclude: 50 | - 'spec/support/data.rb' 51 | 52 | # Offense count: 2 53 | # Configuration parameters: CountComments, CountAsOne. 54 | Metrics/ClassLength: 55 | Max: 128 56 | 57 | # Offense count: 3 58 | # Configuration parameters: ForbiddenDelimiters. 59 | # ForbiddenDelimiters: (?i-mx:(^|\s)(EO[A-Z]{1}|END)(\s|$)) 60 | Naming/HeredocDelimiterNaming: 61 | Exclude: 62 | - 'lib/amoeba/config.rb' 63 | 64 | # Offense count: 2 65 | # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. 66 | # SupportedStyles: snake_case, normalcase, non_integer 67 | # AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 68 | Naming/VariableNumber: 69 | Exclude: 70 | - 'spec/support/data.rb' 71 | 72 | # Offense count: 1 73 | RSpec/BeforeAfterAll: 74 | Exclude: 75 | - '**/spec/spec_helper.rb' 76 | - '**/spec/rails_helper.rb' 77 | - '**/spec/support/**/*.rb' 78 | - 'spec/lib/amoeba_spec.rb' 79 | 80 | # Offense count: 12 81 | # Configuration parameters: Prefixes, AllowedPatterns. 82 | # Prefixes: when, with, without 83 | RSpec/ContextWording: 84 | Exclude: 85 | - 'spec/lib/amoeba_spec.rb' 86 | 87 | # Offense count: 1 88 | # Configuration parameters: IgnoredMetadata. 89 | RSpec/DescribeClass: 90 | Exclude: 91 | - '**/spec/features/**/*' 92 | - '**/spec/requests/**/*' 93 | - '**/spec/routing/**/*' 94 | - '**/spec/system/**/*' 95 | - '**/spec/views/**/*' 96 | - 'spec/lib/amoeba_spec.rb' 97 | 98 | # Offense count: 3 99 | # Configuration parameters: CountAsOne. 100 | RSpec/ExampleLength: 101 | Max: 134 102 | 103 | # Offense count: 1 104 | # Configuration parameters: . 105 | # SupportedStyles: have_received, receive 106 | RSpec/MessageSpies: 107 | EnforcedStyle: receive 108 | 109 | # Offense count: 7 110 | RSpec/MultipleExpectations: 111 | Max: 55 112 | 113 | # Offense count: 18 114 | # Configuration parameters: EnforcedStyle, IgnoreSharedExamples. 115 | # SupportedStyles: always, named_only 116 | RSpec/NamedSubject: 117 | Exclude: 118 | - 'spec/lib/amoeba_spec.rb' 119 | 120 | # Offense count: 1 121 | # This cop supports unsafe autocorrection (--autocorrect-all). 122 | # Configuration parameters: Strict, EnforcedStyle, AllowedExplicitMatchers. 123 | # SupportedStyles: inflected, explicit 124 | RSpec/PredicateMatcher: 125 | Exclude: 126 | - 'spec/lib/amoeba_spec.rb' 127 | 128 | # Offense count: 25 129 | # This cop supports safe autocorrection (--autocorrect). 130 | Style/RedundantConstantBase: 131 | Exclude: 132 | - 'spec/lib/amoeba_spec.rb' 133 | - 'spec/spec_helper.rb' 134 | - 'spec/support/models.rb' 135 | 136 | # Offense count: 1 137 | # This cop supports safe autocorrection (--autocorrect). 138 | Style/RedundantRegexpArgument: 139 | Exclude: 140 | - 'spec/lib/amoeba_spec.rb' 141 | 142 | # Offense count: 5 143 | # This cop supports unsafe autocorrection (--autocorrect-all). 144 | # Configuration parameters: Mode. 145 | Style/StringConcatenation: 146 | Exclude: 147 | - 'spec/lib/amoeba_spec.rb' 148 | - 'spec/spec_helper.rb' 149 | - 'spec/support/models.rb' 150 | -------------------------------------------------------------------------------- /lib/amoeba/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Amoeba 4 | class Config 5 | DEFAULTS = { 6 | enabled: false, 7 | inherit: false, 8 | do_preproc: false, 9 | parenting: false, 10 | raised: false, 11 | dup_method: :dup, 12 | remap_method: nil, 13 | includes: {}, 14 | excludes: {}, 15 | clones: [], 16 | customizations: [], 17 | overrides: [], 18 | null_fields: [], 19 | coercions: {}, 20 | prefixes: {}, 21 | suffixes: {}, 22 | regexes: {}, 23 | known_macros: %i[has_one has_many has_and_belongs_to_many] 24 | }.freeze 25 | 26 | DEFAULTS.each do |key, value| 27 | value.freeze if value.is_a?(Array) || value.is_a?(Hash) 28 | class_eval <<-EOS, __FILE__, __LINE__ + 1 29 | def #{key} # def enabled 30 | @config[:#{key}] # @config[:enabled] 31 | end # end 32 | EOS 33 | end 34 | 35 | def initialize(klass) 36 | @klass = klass 37 | @config = self.class::DEFAULTS.deep_dup 38 | end 39 | 40 | alias upbringing raised 41 | 42 | def enable 43 | @config[:enabled] = true 44 | end 45 | 46 | def disable 47 | @config[:enabled] = false 48 | end 49 | 50 | def raised(style = :submissive) 51 | @config[:raised] = style 52 | end 53 | 54 | def propagate(style = :submissive) 55 | @config[:parenting] ||= style 56 | @config[:inherit] = true 57 | end 58 | 59 | def push_value_to_array(value, key) 60 | res = @config[key] 61 | if value.is_a?(::Array) 62 | res = value 63 | elsif value 64 | res << value 65 | end 66 | @config[key] = res.uniq 67 | end 68 | 69 | def push_array_value_to_hash(value, config_key) 70 | @config[config_key] = {} 71 | 72 | value.each do |definition| 73 | definition.each do |key, val| 74 | fill_hash_value_for(config_key, key, val) 75 | end 76 | end 77 | end 78 | 79 | def push_value_to_hash(value, config_key) 80 | if value.is_a?(Array) 81 | push_array_value_to_hash(value, config_key) 82 | else 83 | value.each do |key, val| 84 | fill_hash_value_for(config_key, key, val) 85 | end 86 | end 87 | @config[config_key] 88 | end 89 | 90 | def fill_hash_value_for(config_key, key, val) 91 | @config[config_key][key] = val if val || (!val.nil? && config_key == :coercions) 92 | end 93 | 94 | def include_association(value = nil, options = {}) 95 | enable 96 | @config[:excludes] = {} 97 | value = value.is_a?(Array) ? value.map! { |v| [v, options] }.to_h : { value => options } 98 | push_value_to_hash(value, :includes) 99 | end 100 | 101 | def include_associations(*values) 102 | values.flatten.each { |v| include_association(v) } 103 | end 104 | 105 | def exclude_association(value = nil, options = {}) 106 | enable 107 | @config[:includes] = {} 108 | value = value.is_a?(Array) ? value.map! { |v| [v, options] }.to_h : { value => options } 109 | push_value_to_hash(value, :excludes) 110 | end 111 | 112 | def exclude_associations(*values) 113 | values.flatten.each { |v| exclude_association(v) } 114 | end 115 | 116 | def clone(value = nil) 117 | enable 118 | push_value_to_array(value, :clones) 119 | end 120 | 121 | def recognize(value = nil) 122 | enable 123 | push_value_to_array(value, :known_macros) 124 | end 125 | 126 | { override: 'overrides', customize: 'customizations', 127 | nullify: 'null_fields' }.each do |method, key| 128 | class_eval <<-EOS, __FILE__, __LINE__ + 1 129 | def #{method}(value = nil) # def override(value = nil) 130 | @config[:do_preproc] = true # @config[:do_preproc] = true 131 | push_value_to_array(value, :#{key}) # push_value_to_array(value, :overrides) 132 | end # end 133 | EOS 134 | end 135 | 136 | { set: 'coercions', prepend: 'prefixes', 137 | append: 'suffixes', regex: 'regexes' }.each do |method, key| 138 | class_eval <<-EOS, __FILE__, __LINE__ + 1 139 | def #{method}(value = nil) # def set(value = nil) 140 | @config[:do_preproc] = true # @config[:do_preproc] = true 141 | push_value_to_hash(value, :#{key}) # push_value_to_hash(value, :coercions) 142 | end # end 143 | EOS 144 | end 145 | 146 | def through(value) 147 | @config[:dup_method] = value.to_sym 148 | end 149 | 150 | def remapper(value) 151 | @config[:remap_method] = value.to_sym 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /spec/support/data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | u1 = User.create(name: 'Robert Johnson', email: 'bob@crossroads.com') 4 | u2 = User.create(name: 'Miles Davis', email: 'miles@kindofblue.com') 5 | 6 | a1 = Author.create(full_name: 'Kermit The Vonnegut', nickname: 'kvsoitgoes') 7 | a2 = Author.create(full_name: 'Arthur Sees Clarck', nickname: 'strangewater') 8 | 9 | t = Topic.create(title: 'Ponies', description: 'Lets talk about my ponies.') 10 | 11 | # First Post {{{ 12 | p1 = t.posts.create(owner: u1, author: a1, title: 'My little pony', 13 | contents: 'Lorum ipsum dolor rainbow bright. I like dogs, dogs are awesome.') 14 | f1 = p1.create_post_config(is_visible: true, is_open: false, password: 'abcdefg123') 15 | a1 = p1.create_account(title: 'Foo') 16 | h1 = p1.account.create_history(some_stuff: 'Bar') 17 | c1 = p1.comments.create(contents: 'I love it!', nerf: 'ratatat') 18 | [5, 5, 4, 3, 5, 5].each { |stars| c1.ratings.create(num_stars: stars) } 19 | 20 | c2 = p1.comments.create(contents: 'I hate it!', nerf: 'whapaow') 21 | [3, 1, 4, 1, 1, 2].each { |stars| c2.ratings.create(num_stars: stars) } 22 | 23 | c3 = p1.comments.create(contents: 'kthxbbq!!11!!!1!eleven!!', nerf: 'bonk') 24 | [0, 0, 1, 2, 1, 0].each { |stars| c3.ratings.create(num_stars: stars) } 25 | 26 | %w[funny wtf cats].each { |value| p1.tags << Tag.create(value: value) } 27 | 28 | ['My Sidebar', 'Photo Gallery', 'Share & Like'].each do |value| 29 | p1.widgets << Widget.create(value: value) 30 | end 31 | 32 | ['This is important', "You've been warned", "Don't forget"].each do |value| 33 | p1.notes << Note.create(value: value) 34 | end 35 | 36 | p1.save 37 | 38 | c1 = Category.create(title: 'Umbrellas', description: 'Clown fart') 39 | c2 = Category.create(title: 'Widgets', description: 'Humpty dumpty') 40 | c3 = Category.create(title: 'Wombats', description: 'Slushy mushy') 41 | 42 | s1 = Supercat.create(post: p1, category: c1, ramblings: 'zomg', other_ramblings: 'nerp') 43 | s2 = Supercat.create(post: p1, category: c2, ramblings: 'why', other_ramblings: 'narp') 44 | s3 = Supercat.create(post: p1, category: c3, ramblings: 'ohnoes', other_ramblings: 'blap') 45 | 46 | s1.superkittens.create(value: 'Fluffy') 47 | s1.superkittens.create(value: 'Buffy') 48 | s1.superkittens.create(value: 'Fuzzy') 49 | 50 | s2.superkittens.create(value: 'Hairball') 51 | s2.superkittens.create(value: 'Crosseye') 52 | s2.superkittens.create(value: 'Spot') 53 | 54 | s3.superkittens.create(value: 'Dopey') 55 | s3.superkittens.create(value: 'Sneezy') 56 | s3.superkittens.create(value: 'Sleepy') 57 | 58 | p1.custom_things.create([{ value: [1, 2] }, { value: [] }, { value: [78] }]) 59 | # }}} 60 | 61 | # Product {{{ 62 | product1 = Product.create(title: 'Sticky Notes 5-Pak', price: 5.99, weight: 0.56) 63 | shirt1 = Shirt.create(title: 'Fancy Shirt', price: 48.95, sleeve: 32, collar: 15.5) 64 | necklace1 = Necklace.create(title: 'Pearl Necklace', price: 2995.99, length: 18, metal: '14k') 65 | 66 | img1 = product1.images.create(filename: 'sticky.jpg') 67 | img2 = product1.images.create(filename: 'notes.jpg') 68 | 69 | img1 = shirt1.images.create(filename: '02948u31.jpg') 70 | img2 = shirt1.images.create(filename: 'zsif8327.jpg') 71 | 72 | img1 = necklace1.images.create(filename: 'ae02x9f1.jpg') 73 | img2 = necklace1.images.create(filename: 'cba9f912.jpg') 74 | 75 | office = Section.create(name: 'Office', num_employees: 2, total_sales: '1234.56') 76 | supplies = Section.create(name: 'Supplies', num_employees: 1, total_sales: '543.21') 77 | mens = Section.create(name: 'Mens', num_employees: 3, total_sales: '11982.63') 78 | apparel = Section.create(name: 'Apparel', num_employees: 5, total_sales: '1315.20') 79 | accessories = Section.create(name: 'Accessories', num_employees: 1, total_sales: '8992.34') 80 | jewelry = Section.create(name: 'Jewelry', num_employees: 3, total_sales: '25481.77') 81 | 82 | product1.sections << office 83 | product1.sections << supplies 84 | product1.save 85 | 86 | shirt1.sections << mens 87 | shirt1.sections << apparel 88 | shirt1.save 89 | 90 | necklace1.sections << jewelry 91 | necklace1.sections << accessories 92 | necklace1.save 93 | 94 | company = Company.create(name: 'ABC Industries') 95 | employee = company.employees.create(name: 'Joe', ssn: '1111111111', salary: 10_000.0) 96 | employee_address = employee.addresses.create(street: '123 My Street', unit: '103', city: 'Hollywood', 97 | state: 'CA', zip: '90210') 98 | employee_address_2 = employee.addresses.create(street: '124 My Street', unit: '103', city: 'Follywood', 99 | state: 'CA', zip: '90210') 100 | employee_photo = employee.photos.create(name: 'Portrait', size: 12_345) 101 | customer = company.customers.create(email: 'my@email.address', password: 'password') 102 | customer_address = customer.addresses.create(street: '321 My Street', unit: '301', city: 'Bollywood', 103 | state: 'IN', zip: '11111') 104 | customer_address_2 = customer.addresses.create(street: '321 My Drive', unit: '311', city: 'Mollywood', 105 | state: 'IN', zip: '21111') 106 | customer_photo = customer.photos.create(name: 'Mug Shot', size: 54_321) 107 | 108 | # }}} 109 | -------------------------------------------------------------------------------- /lib/amoeba/cloner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | 5 | module Amoeba 6 | class Cloner 7 | extend Forwardable 8 | 9 | attr_reader :new_object, :old_object, :object_klass 10 | 11 | def_delegators :old_object, :_parent_amoeba, :_amoeba_settings, 12 | :_parent_amoeba_settings 13 | 14 | def_delegators :object_klass, :amoeba, :fresh_amoeba, :reset_amoeba 15 | 16 | def initialize(object, options = {}) 17 | @old_object = object 18 | @options = options 19 | @object_klass = @old_object.class 20 | inherit_parent_settings 21 | @new_object = object.__send__(amoeba.dup_method) 22 | end 23 | 24 | def run 25 | process_overrides 26 | apply if amoeba.enabled 27 | after_apply if amoeba.do_preproc 28 | @new_object 29 | end 30 | 31 | private 32 | 33 | def parenting_style 34 | amoeba.upbringing || _parent_amoeba.parenting 35 | end 36 | 37 | def inherit_strict_parent_settings 38 | fresh_amoeba(&_parent_amoeba_settings) 39 | end 40 | 41 | def inherit_relaxed_parent_settings 42 | amoeba(&_parent_amoeba_settings) 43 | end 44 | 45 | def inherit_submissive_parent_settings 46 | reset_amoeba(&_amoeba_settings) 47 | amoeba(&_parent_amoeba_settings) 48 | amoeba(&_amoeba_settings) 49 | end 50 | 51 | def inherit_parent_settings 52 | return unless _parent_amoeba.inherit 53 | return unless %w[strict relaxed submissive].include?(parenting_style.to_s) 54 | 55 | __send__(:"inherit_#{parenting_style}_parent_settings") 56 | end 57 | 58 | def apply_clones 59 | amoeba.clones.each do |clone_field| 60 | exclude_clone_if_has_many_through(clone_field) 61 | end 62 | end 63 | 64 | def exclude_clone_if_has_many_through(clone_field) 65 | association = @object_klass.reflect_on_association(clone_field) 66 | 67 | # if this is a has many through and we're gonna deep 68 | # copy the child records, exclude the regular join 69 | # table from copying so we don't end up with the new 70 | # and old children on the copy 71 | return unless association.macro == :has_many || 72 | association.is_a?(::ActiveRecord::Reflection::ThroughReflection) 73 | 74 | amoeba.exclude_association(association.options[:through]) 75 | end 76 | 77 | def follow_only_includes 78 | amoeba.includes.each do |include, options| 79 | next if options[:if] && !@old_object.send(options[:if]) 80 | 81 | follow_association(include, @object_klass.reflect_on_association(include)) 82 | end 83 | end 84 | 85 | def follow_all_except_excludes 86 | @object_klass.reflections.each do |name, association| 87 | exclude = amoeba.excludes[name.to_sym] 88 | next if exclude && (exclude.blank? || @old_object.send(exclude[:if])) 89 | 90 | follow_association(name, association) 91 | end 92 | end 93 | 94 | def follow_all 95 | @object_klass.reflections.each do |name, association| 96 | follow_association(name, association) 97 | end 98 | end 99 | 100 | def apply_associations 101 | if amoeba.includes.present? 102 | follow_only_includes 103 | elsif amoeba.excludes.present? 104 | follow_all_except_excludes 105 | else 106 | follow_all 107 | end 108 | end 109 | 110 | def apply 111 | apply_clones 112 | apply_associations 113 | end 114 | 115 | def follow_association(relation_name, association) 116 | return unless amoeba.known_macros.include?(association.macro.to_sym) 117 | 118 | follow_klass = ::Amoeba::Macros.list[association.macro.to_sym] 119 | follow_klass&.new(self)&.follow(relation_name, association) 120 | end 121 | 122 | def process_overrides 123 | amoeba.overrides.each do |block| 124 | block.call(@old_object, @new_object) 125 | end 126 | end 127 | 128 | def process_null_fields 129 | # nullify any fields the user has configured 130 | amoeba.null_fields.each do |field_key| 131 | @new_object[field_key] = nil 132 | end 133 | end 134 | 135 | def process_coercions 136 | # prepend any extra strings to indicate uniqueness of the new record(s) 137 | amoeba.coercions.each do |field, coercion| 138 | @new_object[field] = coercion.to_s 139 | end 140 | end 141 | 142 | def process_prefixes 143 | # prepend any extra strings to indicate uniqueness of the new record(s) 144 | amoeba.prefixes.each do |field, prefix| 145 | @new_object[field] = "#{prefix}#{@new_object[field]}" 146 | end 147 | end 148 | 149 | def process_suffixes 150 | # postpend any extra strings to indicate uniqueness of the new record(s) 151 | amoeba.suffixes.each do |field, suffix| 152 | @new_object[field] = "#{@new_object[field]}#{suffix}" 153 | end 154 | end 155 | 156 | def process_regexes 157 | # regex any fields that need changing 158 | amoeba.regexes.each do |field, action| 159 | @new_object[field].gsub!(action[:replace], action[:with]) 160 | end 161 | end 162 | 163 | def process_customizations 164 | # prepend any extra strings to indicate uniqueness of the new record(s) 165 | amoeba.customizations.each do |block| 166 | block.call(@old_object, @new_object) 167 | end 168 | end 169 | 170 | def after_apply 171 | process_null_fields 172 | process_coercions 173 | process_prefixes 174 | process_suffixes 175 | process_regexes 176 | process_customizations 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define do 4 | self.verbose = false 5 | 6 | create_table :topics, force: true do |t| 7 | t.string :title 8 | t.string :description 9 | t.timestamps null: true 10 | end 11 | 12 | create_table :posts, force: true do |t| 13 | t.integer :topic_id 14 | t.integer :owner_id 15 | t.integer :author_id 16 | t.string :title 17 | t.string :contents 18 | t.timestamps null: true 19 | end 20 | 21 | create_table :products, force: true do |t| 22 | t.string :type 23 | t.string :title 24 | t.decimal :price 25 | t.decimal :weight 26 | t.decimal :cost 27 | t.decimal :sleeve 28 | t.decimal :collar 29 | t.decimal :length 30 | t.string :metal 31 | end 32 | 33 | create_table :products_sections, force: true do |t| 34 | t.integer :section_id 35 | t.integer :product_id 36 | end 37 | 38 | create_table :sections, force: true do |t| 39 | t.string :name 40 | t.integer :num_employees 41 | t.decimal :total_sales 42 | end 43 | 44 | create_table :images, force: true do |t| 45 | t.string :filename 46 | t.integer :product_id 47 | end 48 | 49 | create_table :companies, force: true do |t| 50 | t.string :name 51 | end 52 | 53 | create_table :employees, force: true do |t| 54 | t.integer :company_id 55 | t.string :name 56 | t.string :ssn 57 | t.decimal :salary 58 | end 59 | 60 | create_table :customers, force: true do |t| 61 | t.integer :company_id 62 | t.string :email 63 | t.string :password 64 | t.decimal :balance 65 | end 66 | 67 | create_table :addresses, force: true do |t| 68 | t.integer :addressable_id 69 | t.string :addressable_type 70 | 71 | t.string :street 72 | t.string :unit 73 | t.string :city 74 | t.string :state 75 | t.string :zip 76 | end 77 | 78 | create_table :photos, force: true do |t| 79 | t.integer :imageable_id 80 | t.string :imageable_type 81 | 82 | t.string :name 83 | t.integer :size 84 | end 85 | 86 | create_table :post_configs, force: true do |t| 87 | t.integer :post_id 88 | t.integer :is_visible 89 | t.integer :is_open 90 | t.string :password 91 | t.timestamps null: true 92 | end 93 | 94 | create_table :comments, force: true do |t| 95 | t.integer :post_id 96 | t.string :contents 97 | t.timestamps null: true 98 | end 99 | 100 | create_table :custom_things, force: true do |t| 101 | t.integer :post_id 102 | t.string :value 103 | t.timestamps null: true 104 | end 105 | 106 | create_table :comments, force: true do |t| 107 | t.integer :post_id 108 | t.string :contents 109 | t.string :nerf 110 | t.timestamps null: true 111 | end 112 | 113 | create_table :ratings, force: true do |t| 114 | t.integer :comment_id 115 | t.string :num_stars 116 | t.timestamps null: true 117 | end 118 | 119 | create_table :tags, force: true do |t| 120 | t.string :value 121 | t.timestamps null: true 122 | end 123 | 124 | create_table :users, force: true do |t| 125 | t.integer :post_id 126 | t.string :name 127 | t.string :email 128 | t.timestamps null: true 129 | end 130 | 131 | create_table :authors, force: true do |t| 132 | t.string :full_name 133 | t.string :nickname 134 | t.timestamps null: true 135 | end 136 | 137 | create_table :posts_tags, force: true do |t| 138 | t.integer :post_id 139 | t.integer :tag_id 140 | end 141 | 142 | create_table :notes, force: true do |t| 143 | t.string :value 144 | t.timestamps null: true 145 | end 146 | 147 | create_table :notes_posts, force: true do |t| 148 | t.integer :post_id 149 | t.integer :note_id 150 | end 151 | 152 | create_table :widgets, force: true do |t| 153 | t.string :value 154 | end 155 | 156 | create_table :post_widgets, force: true do |t| 157 | t.integer :post_id 158 | t.integer :widget_id 159 | end 160 | 161 | create_table :categories, force: true do |t| 162 | t.string :title 163 | t.string :description 164 | end 165 | 166 | create_table :supercats, force: true do |t| 167 | t.integer :post_id 168 | t.integer :category_id 169 | t.string :ramblings 170 | t.string :other_ramblings 171 | t.timestamps null: true 172 | end 173 | 174 | create_table :superkittens, force: true do |t| 175 | t.integer :supercat_id 176 | t.string :value 177 | t.timestamps null: true 178 | end 179 | 180 | create_table :accounts, force: true do |t| 181 | t.integer :post_id 182 | t.string :title 183 | t.timestamps null: true 184 | end 185 | 186 | create_table :histories, force: true do |t| 187 | t.integer :account_id 188 | t.string :some_stuff 189 | t.timestamps null: true 190 | end 191 | 192 | create_table :metal_objects, force: true do |t| 193 | t.string :type 194 | t.integer :parent_id 195 | t.timestamps null: true 196 | end 197 | 198 | create_table :super_admins, force: true do |t| 199 | t.string :email 200 | t.string :password 201 | t.boolean :active, null: false, default: true 202 | t.timestamps null: true 203 | end 204 | 205 | create_table :boxes, force: true do |t| 206 | t.string :title 207 | t.timestamps null: true 208 | end 209 | 210 | create_table :box_products, force: true do |t| 211 | t.string :type 212 | t.integer :box_id 213 | t.integer :box_sub_product_id 214 | t.string :title 215 | t.timestamps null: true 216 | end 217 | 218 | create_table :stages, force: true do |t| 219 | t.string :title 220 | t.string :type 221 | t.integer :external_id 222 | t.timestamps null: true 223 | end 224 | 225 | create_table :participants, force: true do |t| 226 | t.string :name 227 | t.string :type 228 | t.integer :stage_id 229 | t.timestamps 230 | end 231 | 232 | create_table :custom_rules, force: true do |t| 233 | t.string :description 234 | t.integer :stage_id 235 | t.timestamps 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Topic < ActiveRecord::Base 4 | has_many :posts 5 | end 6 | 7 | class User < ActiveRecord::Base 8 | has_many :posts 9 | end 10 | 11 | class Author < ActiveRecord::Base 12 | has_many :posts, inverse_of: :author 13 | amoeba do 14 | enable 15 | end 16 | end 17 | 18 | class Post < ActiveRecord::Base 19 | belongs_to :topic 20 | belongs_to :owner, class_name: 'User' 21 | belongs_to :author, inverse_of: :posts 22 | has_one :post_config 23 | has_one :account 24 | has_one :history, through: :account 25 | has_many :comments 26 | has_many :supercats 27 | has_many :categories, through: :supercats 28 | has_many :post_widgets 29 | has_many :widgets, through: :post_widgets 30 | has_many :custom_things 31 | has_and_belongs_to_many :tags 32 | has_and_belongs_to_many :notes 33 | 34 | validates_presence_of :topic 35 | validates_presence_of :author 36 | 37 | amoeba do 38 | enable 39 | clone %i[widgets notes] 40 | prepend title: 'Copy of ' 41 | append contents: ' (copied version)' 42 | regex contents: { replace: /dog/, with: 'cat' } 43 | customize([ 44 | lambda do |orig_obj, copy_of_obj| 45 | orig_obj.comments.each do |oc| 46 | next unless oc.nerf == 'ratatat' 47 | 48 | hash = oc.attributes 49 | hash[:id] = nil 50 | hash[:post_id] = nil 51 | hash[:contents] = nil 52 | 53 | cc = Comment.new(hash) 54 | 55 | copy_of_obj.comments << cc 56 | end 57 | end, 58 | lambda do |orig_obj, copy_of_obj| 59 | orig_obj.comments.each do |oc| 60 | next unless oc.nerf == 'bonk' 61 | 62 | hash = oc.attributes 63 | hash[:id] = nil 64 | hash[:post_id] = nil 65 | hash[:contents] = nil 66 | hash[:nerf] = 'bonkers' 67 | 68 | cc = Comment.new(hash) 69 | 70 | copy_of_obj.comments << cc 71 | end 72 | end 73 | ]) 74 | end 75 | 76 | def truthy? 77 | true 78 | end 79 | 80 | def falsey? 81 | false 82 | end 83 | 84 | class << self 85 | def tag_count 86 | ActiveRecord::Base.connection.select_one('SELECT COUNT(*) AS tag_count FROM posts_tags')['tag_count'] 87 | end 88 | 89 | def note_count 90 | ActiveRecord::Base.connection.select_one('SELECT COUNT(*) AS note_count FROM notes_posts')['note_count'] 91 | end 92 | end 93 | end 94 | 95 | class CustomThing < ActiveRecord::Base 96 | belongs_to :post 97 | 98 | class ArrayPack 99 | def self.load(str) 100 | return [] unless str.present? 101 | return str if str.is_a?(Array) 102 | 103 | str.split(',').map(&:to_i) 104 | end 105 | 106 | def self.dump(int_array) 107 | return '' unless int_array.present? 108 | 109 | int_array.join(',') 110 | end 111 | end 112 | 113 | serialize :value, coder: ArrayPack 114 | 115 | before_create :hydrate_me 116 | 117 | def hydrate_me 118 | self.value = value 119 | end 120 | end 121 | 122 | class Account < ActiveRecord::Base 123 | belongs_to :post 124 | has_one :history 125 | 126 | amoeba do 127 | enable 128 | end 129 | end 130 | 131 | class History < ActiveRecord::Base 132 | belongs_to :account 133 | end 134 | 135 | class Category < ActiveRecord::Base 136 | has_many :supercats 137 | has_many :posts, through: :supercats 138 | 139 | amoeba do 140 | enable 141 | prepend ramblings: 'Copy of ' 142 | set other_ramblings: 'La la la' 143 | end 144 | end 145 | 146 | class Supercat < ActiveRecord::Base 147 | belongs_to :post 148 | belongs_to :category 149 | has_many :superkittens 150 | 151 | amoeba do 152 | include_association :superkittens 153 | prepend ramblings: 'Copy of ' 154 | set other_ramblings: 'La la la' 155 | end 156 | end 157 | 158 | class Superkitten < ActiveRecord::Base 159 | belongs_to :supercat 160 | end 161 | 162 | class PostConfig < ActiveRecord::Base 163 | belongs_to :post 164 | end 165 | 166 | class Comment < ActiveRecord::Base 167 | belongs_to :post 168 | has_many :ratings 169 | has_many :reviews 170 | 171 | amoeba do 172 | exclude_association :reviews 173 | end 174 | end 175 | 176 | class Review < ActiveRecord::Base 177 | belongs_to :comment 178 | end 179 | 180 | class Rating < ActiveRecord::Base 181 | belongs_to :comment 182 | end 183 | 184 | class Widget < ActiveRecord::Base 185 | has_many :post_widgets 186 | has_many :posts, through: :post_widgets 187 | end 188 | 189 | class PostWidget < ActiveRecord::Base 190 | belongs_to :post 191 | belongs_to :widget 192 | end 193 | 194 | class Tag < ActiveRecord::Base 195 | has_and_belongs_to_many :posts 196 | end 197 | 198 | class Note < ActiveRecord::Base 199 | has_and_belongs_to_many :posts 200 | end 201 | 202 | # Inheritance 203 | class Product < ActiveRecord::Base 204 | has_many :images 205 | has_and_belongs_to_many :sections 206 | 207 | SECTION_COUNT_QUERY = 'SELECT COUNT(*) AS section_count FROM products_sections WHERE product_id = ?' 208 | 209 | amoeba do 210 | enable 211 | propagate 212 | end 213 | 214 | def section_count 215 | ActiveRecord::Base.connection.select_one(SECTION_COUNT_QUERY, id)['section_count'] 216 | end 217 | end 218 | 219 | class Section < ActiveRecord::Base 220 | end 221 | 222 | class Image < ActiveRecord::Base 223 | end 224 | 225 | class Shirt < Product 226 | amoeba do 227 | raised :submissive 228 | end 229 | end 230 | 231 | class Necklace < Product 232 | # Strange bug on rbx 233 | if defined?(::Rubinius) 234 | after_initialize :set_type 235 | 236 | def set_type 237 | self.type = 'Necklace' 238 | end 239 | end 240 | 241 | amoeba do 242 | raised :relaxed 243 | end 244 | end 245 | 246 | class BlackBox < Product 247 | amoeba do 248 | propagate :strict 249 | end 250 | end 251 | 252 | class SuperBlackBox < BlackBox 253 | end 254 | 255 | # Polymorphism 256 | class Address < ActiveRecord::Base 257 | belongs_to :addressable, polymorphic: true 258 | 259 | amoeba do 260 | enable 261 | end 262 | end 263 | 264 | class Photo < ActiveRecord::Base 265 | belongs_to :imageable, polymorphic: true 266 | 267 | amoeba do 268 | customize(lambda { |original_photo, new_photo| 269 | new_photo.name = original_photo.name.to_s + ' Copy' 270 | }) 271 | end 272 | end 273 | 274 | class Company < ActiveRecord::Base 275 | has_many :employees 276 | has_many :customers 277 | 278 | amoeba do 279 | include_associations :employees, 280 | :customers 281 | end 282 | end 283 | 284 | class Employee < ActiveRecord::Base 285 | has_many :addresses, as: :addressable 286 | has_many :photos, as: :imageable 287 | belongs_to :company 288 | 289 | amoeba do 290 | include_associations %i[addresses photos] 291 | end 292 | end 293 | 294 | class Customer < ActiveRecord::Base 295 | has_many :addresses, as: :addressable 296 | has_many :photos, as: :imageable 297 | belongs_to :company 298 | 299 | amoeba do 300 | enable 301 | end 302 | end 303 | 304 | # Remapping and Method 305 | 306 | class MetalObject < ActiveRecord::Base 307 | end 308 | 309 | class ObjectPrototype < MetalObject 310 | has_many :subobject_prototypes, foreign_key: :parent_id 311 | 312 | amoeba do 313 | enable 314 | through :become_real 315 | remapper :remap_subobjects 316 | end 317 | 318 | def become_real 319 | dup.becomes RealObject 320 | end 321 | 322 | def remap_subobjects(relation_name) 323 | :subobjects if relation_name == :subobject_prototypes 324 | end 325 | end 326 | 327 | class RealObject < MetalObject 328 | has_many :subobjects, foreign_key: :parent_id 329 | end 330 | 331 | class SubobjectPrototype < MetalObject 332 | amoeba do 333 | enable 334 | through :become_subobject 335 | end 336 | 337 | def become_subobject 338 | dup.becomes Subobject 339 | end 340 | end 341 | 342 | class Subobject < MetalObject 343 | end 344 | 345 | # Check of changing boolean attributes 346 | 347 | class SuperAdmin < ::ActiveRecord::Base 348 | amoeba do 349 | set active: false 350 | prepend password: false 351 | end 352 | end 353 | 354 | # Proper inheritance 355 | 356 | class Box < ActiveRecord::Base 357 | has_many :products, class_name: 'BoxProduct' 358 | has_many :sub_products, class_name: 'BoxSubProduct' 359 | 360 | amoeba do 361 | enable 362 | end 363 | end 364 | 365 | class BoxProduct < ActiveRecord::Base 366 | belongs_to :box, class_name: 'Box' 367 | 368 | amoeba do 369 | enable 370 | propagate 371 | end 372 | end 373 | 374 | class BoxSubProduct < BoxProduct 375 | has_one :another_product, class_name: 'BoxAnotherProduct' 376 | end 377 | 378 | class BoxSubSubProduct < BoxSubProduct 379 | end 380 | 381 | class BoxAnotherProduct < BoxProduct 382 | belongs_to :sub_product, class_name: 'BoxSubProduct' 383 | end 384 | 385 | # Inclusion inheritance 386 | class Stage < ActiveRecord::Base 387 | has_many :listeners 388 | has_many :specialists 389 | 390 | amoeba do 391 | include_association :listeners 392 | include_association :specialists 393 | nullify :external_id 394 | propagate 395 | end 396 | end 397 | 398 | class Participant < ActiveRecord::Base 399 | belongs_to :stage 400 | end 401 | 402 | class Listener < Participant 403 | end 404 | 405 | class Specialist < Participant 406 | end 407 | 408 | class CustomStage < Stage 409 | has_many :custom_rules, foreign_key: :stage_id 410 | 411 | amoeba do 412 | include_association :custom_rules 413 | end 414 | end 415 | 416 | class CustomRule < ActiveRecord::Base 417 | belongs_to :custom_stage 418 | end 419 | -------------------------------------------------------------------------------- /spec/lib/amoeba_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'amoeba' do 6 | context 'dup' do 7 | before do 8 | require ::File.dirname(__FILE__) + '/../support/data.rb' 9 | end 10 | 11 | let(:first_product) { Product.find(1) } 12 | 13 | it 'duplicates associated child records' do 14 | # Posts {{{ 15 | old_post = ::Post.find(1) 16 | expect(old_post.comments.map(&:contents).include?('I love it!')).to be_truthy 17 | 18 | old_post.class.amoeba do 19 | prepend contents: "Here's a copy: " 20 | end 21 | 22 | new_post = old_post.amoeba_dup 23 | 24 | start_account_count = Account.all.count 25 | start_history_count = History.all.count 26 | start_cat_count = Category.all.count 27 | start_supercat_count = Supercat.all.count 28 | start_tag_count = Tag.all.count 29 | start_note_count = Note.all.count 30 | start_widget_count = Widget.all.count 31 | start_post_count = Post.all.count 32 | start_comment_count = Comment.all.count 33 | start_rating_count = Rating.all.count 34 | start_postconfig_count = PostConfig.all.count 35 | start_postwidget_count = PostWidget.all.count 36 | start_superkitten_count = Superkitten.all.count 37 | start_posttag_count = Post.tag_count 38 | start_postnote_count = Post.note_count 39 | 40 | expect(new_post.save!).to be_truthy 41 | expect(new_post.title).to eq("Copy of #{old_post.title}") 42 | 43 | end_account_count = Account.count 44 | end_history_count = History.count 45 | end_cat_count = Category.count 46 | end_supercat_count = Supercat.count 47 | end_tag_count = Tag.all.count 48 | end_note_count = Note.all.count 49 | end_widget_count = Widget.all.count 50 | end_post_count = Post.all.count 51 | end_comment_count = Comment.all.count 52 | end_rating_count = Rating.all.count 53 | end_postconfig_count = PostConfig.all.count 54 | end_postwidget_count = PostWidget.all.count 55 | end_superkitten_count = Superkitten.all.count 56 | end_posttag_count = Post.tag_count 57 | end_postnote_count = Post.note_count 58 | 59 | expect(end_tag_count).to eq(start_tag_count) 60 | expect(end_cat_count).to eq(start_cat_count) 61 | expect(end_account_count).to eq(start_account_count * 2) 62 | expect(end_history_count).to eq(start_history_count * 2) 63 | expect(end_supercat_count).to eq(start_supercat_count * 2) 64 | expect(end_post_count).to eq(start_post_count * 2) 65 | expect(end_comment_count).to eq((start_comment_count * 2) + 2) 66 | expect(end_rating_count).to eq(start_rating_count * 2) 67 | expect(end_postconfig_count).to eq(start_postconfig_count * 2) 68 | expect(end_posttag_count).to eq(start_posttag_count * 2) 69 | expect(end_widget_count).to eq(start_widget_count * 2) 70 | expect(end_postwidget_count).to eq(start_postwidget_count * 2) 71 | expect(end_note_count).to eq(start_note_count * 2) 72 | expect(end_postnote_count).to eq(start_postnote_count * 2) 73 | expect(end_superkitten_count).to eq(start_superkitten_count * 2) 74 | 75 | expect(new_post.supercats.map(&:ramblings)).to include('Copy of zomg') 76 | expect(new_post.supercats.map(&:other_ramblings).uniq.length).to eq(1) 77 | expect(new_post.supercats.map(&:other_ramblings).uniq).to include('La la la') 78 | expect(new_post.contents).to eq("Here's a copy: #{old_post.contents.gsub(/dog/, 79 | 'cat')} (copied version)") 80 | expect(new_post.comments.length).to eq(5) 81 | expect(new_post.comments.select do |c| 82 | c.nerf == 'ratatat' && c.contents.nil? 83 | end.length).to eq(1) 84 | expect(new_post.comments.select { |c| c.nerf == 'ratatat' }.length).to eq(2) 85 | expect(new_post.comments.select { |c| c.nerf == 'bonk' }.length).to eq(1) 86 | expect(new_post.comments.select do |c| 87 | c.nerf == 'bonkers' && c.contents.nil? 88 | end.length).to eq(1) 89 | 90 | new_post.widgets.map(&:id).each do |id| 91 | expect(old_post.widgets.map(&:id)).not_to include(id) 92 | end 93 | 94 | expect(new_post.custom_things.length).to eq(3) 95 | expect(new_post.custom_things.select { |ct| ct.value == [] }.length).to eq(1) 96 | expect(new_post.custom_things.select { |ct| ct.value == [1, 2] }.length).to eq(1) 97 | expect(new_post.custom_things.select { |ct| ct.value == [78] }.length).to eq(1) 98 | # }}} 99 | # Author {{{ 100 | old_author = Author.find(1) 101 | new_author = old_author.amoeba_dup 102 | new_author.save! 103 | expect(new_author.errors.messages).to be_empty 104 | expect(new_author.posts.first.custom_things.length).to eq(3) 105 | expect(new_author.posts.first.custom_things.select { |ct| ct.value == [] }.length).to eq(1) 106 | expect(new_author.posts.first.custom_things.select do |ct| 107 | ct.value == [1, 2] 108 | end.length).to eq(1) 109 | expect(new_author.posts.first.custom_things.select { |ct| ct.value == [78] }.length).to eq(1) 110 | # }}} 111 | # Products {{{ 112 | # Base Class {{{ 113 | 114 | start_image_count = first_product.images.count 115 | start_section_count = Section.all.length 116 | start_prodsection_count = first_product.section_count 117 | 118 | new_product = first_product.amoeba_dup 119 | new_product.save 120 | expect(new_product.errors.messages).to be_empty 121 | 122 | end_image_count = first_product.images.count 123 | end_newimage_count = new_product.images.count 124 | end_section_count = Section.all.length 125 | end_prodsection_count = first_product.section_count 126 | end_newprodsection_count = new_product.section_count 127 | 128 | expect(end_image_count).to eq(start_image_count) 129 | expect(end_newimage_count).to eq(start_image_count) 130 | expect(end_section_count).to eq(start_section_count) 131 | expect(end_prodsection_count).to eq(start_prodsection_count) 132 | expect(end_newprodsection_count).to eq(start_prodsection_count) 133 | # }}} 134 | 135 | # Inherited Class {{{ 136 | # Shirt {{{ 137 | old_product = Shirt.find(2) 138 | 139 | start_image_count = old_product.images.count 140 | start_section_count = Section.count 141 | start_prodsection_count = old_product.section_count 142 | 143 | new_product = old_product.amoeba_dup 144 | new_product.save 145 | expect(new_product.errors.messages).to be_empty 146 | 147 | end_image_count = old_product.images.count 148 | end_newimage_count = new_product.images.count 149 | end_section_count = Section.count 150 | end_prodsection_count = first_product.section_count 151 | end_newprodsection_count = new_product.section_count 152 | 153 | expect(end_image_count).to eq(start_image_count) 154 | expect(end_newimage_count).to eq(start_image_count) 155 | expect(end_section_count).to eq(start_section_count) 156 | expect(end_prodsection_count).to eq(start_prodsection_count) 157 | expect(end_newprodsection_count).to eq(start_prodsection_count) 158 | # }}} 159 | 160 | # Necklace {{{ 161 | old_product = Necklace.find(3) 162 | 163 | start_image_count = old_product.images.count 164 | start_section_count = Section.count 165 | start_prodsection_count = old_product.section_count 166 | 167 | new_product = old_product.amoeba_dup 168 | new_product.save 169 | expect(new_product.errors.messages).to be_empty 170 | 171 | end_image_count = old_product.images.count 172 | end_newimage_count = new_product.images.count 173 | end_section_count = Section.count 174 | end_prodsection_count = first_product.section_count 175 | end_newprodsection_count = new_product.section_count 176 | 177 | expect(end_image_count).to eq(start_image_count) 178 | expect(end_newimage_count).to eq(start_image_count) 179 | expect(end_section_count).to eq(start_section_count) 180 | expect(end_prodsection_count).to eq(start_prodsection_count) 181 | expect(end_newprodsection_count).to eq(start_prodsection_count) 182 | # }}} 183 | # }}} 184 | # }}} 185 | end 186 | end 187 | 188 | context 'Using a if condition' do 189 | subject { post.amoeba_dup.save! } 190 | 191 | before(:all) do 192 | require ::File.dirname(__FILE__) + '/../support/data.rb' 193 | end 194 | 195 | before { ::Post.fresh_amoeba } 196 | 197 | let(:post) { Post.first } 198 | 199 | it 'includes an association with truthy condition' do 200 | ::Post.amoeba do 201 | include_association :comments, if: :truthy? 202 | end 203 | expect { subject }.to change(Comment, :count).by(3) 204 | end 205 | 206 | it 'does not include an association with a falsey condition' do 207 | ::Post.amoeba do 208 | include_association :comments, if: :falsey? 209 | end 210 | expect { subject }.not_to change(Comment, :count) 211 | end 212 | 213 | it 'excludes an association with a truthy condition' do 214 | ::Post.amoeba do 215 | exclude_association :comments, if: :truthy? 216 | end 217 | expect { subject }.not_to change(Comment, :count) 218 | end 219 | 220 | it 'does not exclude an association with a falsey condition' do 221 | ::Post.amoeba do 222 | exclude_association :comments, if: :falsey? 223 | end 224 | expect { subject }.to change(Comment, :count).by(3) 225 | end 226 | 227 | it 'includes associations from a given array with a truthy condition' do 228 | ::Post.amoeba do 229 | include_association [:comments], if: :truthy? 230 | end 231 | expect { subject }.to change(Comment, :count).by(3) 232 | end 233 | 234 | it 'does not include associations from a given array with a falsey condition' do 235 | ::Post.amoeba do 236 | include_association [:comments], if: :falsey? 237 | end 238 | expect { subject }.not_to change(Comment, :count) 239 | end 240 | 241 | it 'does exclude associations from a given array with a truthy condition' do 242 | ::Post.amoeba do 243 | exclude_association [:comments], if: :truthy? 244 | end 245 | expect { subject }.not_to change(Comment, :count) 246 | end 247 | 248 | it 'does not exclude associations from a given array with a falsey condition' do 249 | ::Post.amoeba do 250 | exclude_association [:comments], if: :falsey? 251 | end 252 | expect { subject }.to change(Comment, :count).by(3) 253 | end 254 | end 255 | 256 | context 'override' do 257 | before do 258 | ::Image.fresh_amoeba 259 | ::Image.amoeba do 260 | override ->(old, new) { new.product_id = 13 if old.filename == 'test.jpg' } 261 | end 262 | end 263 | 264 | it 'overrides fields' do 265 | image = ::Image.create(filename: 'test.jpg', product_id: 12) 266 | image_dup = image.amoeba_dup 267 | expect(image_dup.save).to be_truthy 268 | expect(image_dup.product_id).to eq(13) 269 | end 270 | 271 | it 'does not override fields' do 272 | image = ::Image.create(filename: 'test2.jpg', product_id: 12) 273 | image_dup = image.amoeba_dup 274 | expect(image_dup.save).to be_truthy 275 | expect(image_dup.product_id).to eq(12) 276 | end 277 | end 278 | 279 | context 'nullify' do 280 | before do 281 | ::Image.fresh_amoeba 282 | ::Image.amoeba do 283 | nullify :product_id 284 | end 285 | end 286 | 287 | let(:image) { ::Image.create(filename: 'test.jpg', product_id: 12) } 288 | let(:image_dup) { image.amoeba_dup } 289 | 290 | it 'nullifies fields' do 291 | expect(image_dup.save).to be_truthy 292 | expect(image_dup.product_id).to be_nil 293 | end 294 | end 295 | 296 | context 'strict propagate' do 297 | it 'calls #reset_amoeba' do 298 | expect(::SuperBlackBox).to receive(:reset_amoeba).and_call_original 299 | box = ::SuperBlackBox.create(title: 'Super Black Box', price: 9.99, length: 1, metal: '1') 300 | new_box = box.amoeba_dup 301 | expect(new_box.save).to be_truthy 302 | end 303 | end 304 | 305 | context 'remapping and custom dup method' do 306 | let(:prototype) { ObjectPrototype.new } 307 | 308 | context 'through' do 309 | it do 310 | real_object = prototype.amoeba_dup 311 | expect(real_object).to be_a(::RealObject) 312 | end 313 | end 314 | 315 | context 'remapper' do 316 | it do 317 | prototype.subobject_prototypes << SubobjectPrototype.new 318 | real_object = prototype.amoeba_dup 319 | expect(real_object.subobjects.length).to eq(1) 320 | end 321 | end 322 | end 323 | 324 | context 'preprocessing fields' do 325 | subject { super_admin.amoeba_dup } 326 | 327 | let(:super_admin) do 328 | ::SuperAdmin.create!(email: 'user@example.com', active: true, password: 'password') 329 | end 330 | 331 | it 'accepts "set" to set false to attribute' do 332 | expect(subject.active).to be false 333 | end 334 | 335 | it 'skips "prepend" if it equal to false' do 336 | expect(subject.password).to eq('password') 337 | end 338 | end 339 | 340 | context 'inheritance' do 341 | let(:box) { Box.create } 342 | 343 | it 'does not fail with a deep inheritance' do 344 | sub_sub_product = BoxSubSubProduct.create(title: 'Awesome shoes') 345 | another_product = BoxAnotherProduct.create(title: 'Cleaning product') 346 | sub_sub_product.update(box: box, another_product: another_product) 347 | expect(box.sub_products.first.another_product.title).to eq('Cleaning product') 348 | expect(box.amoeba_dup.sub_products.first.another_product.title).to eq('Cleaning product') 349 | end 350 | end 351 | 352 | context 'inheritance extended' do 353 | subject { stage.amoeba_dup } 354 | 355 | let(:stage) do 356 | stage = CustomStage.new(title: 'My Stage', external_id: 213) 357 | stage.listeners.build(name: 'John') 358 | stage.listeners.build(name: 'Helen') 359 | stage.specialists.build(name: 'Jack') 360 | stage.custom_rules.build(description: 'Kill all humans') 361 | stage.save! 362 | stage 363 | end 364 | 365 | it 'contains parent association and own associations', :aggregate_failures do 366 | subject 367 | expect { subject.save! }.to change(Listener, :count).by(2) 368 | .and change(Specialist, :count).by(1) 369 | .and change( 370 | CustomRule, :count 371 | ).by(1) 372 | 373 | expect(subject.title).to eq 'My Stage' 374 | expect(subject.external_id).to be_nil 375 | expect(subject.listeners.find_by(name: 'John')).not_to be_nil 376 | expect(subject.listeners.find_by(name: 'Helen')).not_to be_nil 377 | expect(subject.specialists.find_by(name: 'Jack')).not_to be_nil 378 | expect(subject.custom_rules.first.description).to eq 'Kill all humans' 379 | end 380 | end 381 | 382 | context 'polymorphic' do 383 | let(:company) { Company.find_by(name: 'ABC Industries') } 384 | let(:new_company) { company.amoeba_dup } 385 | 386 | it 'does not fail with a deep inheritance' do 387 | # employee = company.employees.where(name:'Joe').first 388 | start_company_count = Company.count 389 | start_customer_count = Customer.count 390 | start_employee_count = Employee.count 391 | start_address_count = Address.count 392 | start_photo_count = Photo.count 393 | new_company.name = "Copy of #{new_company.name}" 394 | new_company.save 395 | expect(Company.count).to eq(start_company_count + 1) 396 | expect(Customer.count).to eq(start_customer_count + 1) 397 | expect(Employee.count).to eq(start_employee_count + 1) 398 | expect(Address.count).to eq(start_address_count + 4) 399 | expect(Photo.count).to eq(start_photo_count + 2) 400 | 401 | new_company.reload # fully reload from database 402 | new_company_employees = new_company.employees 403 | expect(new_company_employees.count).to eq(1) 404 | new_company_employee_joe = new_company_employees.find_by(name: 'Joe') 405 | expect(new_company_employee_joe.photos.count).to eq(1) 406 | expect(new_company_employee_joe.photos.first.size).to eq(12_345) 407 | expect(new_company_employee_joe.addresses.count).to eq(2) 408 | expect(new_company_employee_joe.addresses.where(street: '123 My Street').count).to eq(1) 409 | expect(new_company_employee_joe.addresses.where(street: '124 My Street').count).to eq(1) 410 | new_company_customers = new_company.customers 411 | expect(new_company_customers.count).to eq(1) 412 | new_company_customer_my = new_company_customers.where(email: 'my@email.address').first 413 | expect(new_company_customer_my.photos.count).to eq(1) 414 | expect(new_company_customer_my.photos.first.size).to eq(54_321) 415 | expect(new_company_customer_my.addresses.count).to eq(2) 416 | expect(new_company_customer_my.addresses.where(street: '321 My Street').count).to eq(1) 417 | expect(new_company_customer_my.addresses.where(street: '321 My Drive').count).to eq(1) 418 | end 419 | end 420 | end 421 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amoeba 2 | 3 | Easy cloning of active_record objects including associations and several operations under associations and attributes. 4 | 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/d4809ae57ca999fff022/maintainability)](https://codeclimate.com/github/amoeba-rb/amoeba/maintainability) 6 | [![Gem Version](https://badge.fury.io/rb/amoeba.svg)](http://badge.fury.io/rb/amoeba) 7 | [![Build Status](https://travis-ci.org/amoeba-rb/amoeba.svg?branch=master)](https://travis-ci.org/amoeba-rb/amoeba) 8 | 9 | ## Interested in contributing? 10 | 11 | See [here.](docs/contributing.md) 12 | 13 | ## What? 14 | 15 | The goal was to be able to easily and quickly reproduce ActiveRecord objects including their children, for example copying a blog post maintaining its associated tags or categories. 16 | 17 | This gem is named "Amoeba" because amoebas are (small life forms that are) good at reproducing. Their children and grandchildren also reproduce themselves quickly and easily. 18 | 19 | ### Technical Details 20 | 21 | An ActiveRecord extension gem to allow the duplication of associated child record objects when duplicating an active record model. 22 | 23 | Rails 5.2, 6.0, 6.1 compatible. For Rails 4.2 to 5.1 use version 3.x. 24 | 25 | ### Features 26 | 27 | - Supports the following association types 28 | - `has_many` 29 | - `has_one :through` 30 | - `has_many :through` 31 | - `has_and_belongs_to_many` 32 | - A simple DSL for configuration of which fields to copy. The DSL can be applied to your rails models or used on the fly. 33 | - Supports STI (Single Table Inheritance) children inheriting their parent amoeba settings. 34 | - Multiple configuration styles such as inclusive, exclusive and indiscriminate (aka copy everything). 35 | - Supports cloning of the children of Many-to-Many records or merely maintaining original associations 36 | - Supports automatic drill-down i.e. recursive copying of child and grandchild records. 37 | - Supports preprocessing of fields to help indicate uniqueness and ensure the integrity of your data depending on your business logic needs, e.g. prepending "Copy of " or similar text. 38 | - Supports preprocessing of fields with custom lambda blocks so you can do basically whatever you want if, for example, you need some custom logic while making copies. 39 | - Amoeba can perform the following preprocessing operations on fields of copied records 40 | - set 41 | - prepend 42 | - append 43 | - nullify 44 | - customize 45 | - regex 46 | 47 | ## Usage 48 | 49 | ### Installation 50 | 51 | is hopefully as you would expect: 52 | 53 | ```sh 54 | gem install amoeba 55 | ``` 56 | 57 | or just add it to your Gemfile: 58 | 59 | ```sh 60 | gem 'amoeba' 61 | ``` 62 | 63 | Configure your models with one of the styles below and then just run the `amoeba_dup` method on your model where you would run the `dup` method normally: 64 | 65 | ```ruby 66 | p = Post.create(:title => "Hello World!", :content => "Lorum ipsum dolor") 67 | p.comments.create(:content => "I love it!") 68 | p.comments.create(:content => "This sucks!") 69 | 70 | puts Comment.all.count # should be 2 71 | 72 | my_copy = p.amoeba_dup 73 | my_copy.save 74 | 75 | puts Comment.all.count # should be 4 76 | ``` 77 | 78 | By default, when enabled, amoeba will copy any and all associated child records automatically and associate them with the new parent record. 79 | 80 | You can configure the behavior to only include fields that you list or to only include fields that you don't exclude. Of the three, the most performant will be the indiscriminate style, followed by the inclusive style, and the exclusive style will be the slowest because of the need for an extra explicit check on each field. This performance difference is likely negligible enough that you can choose the style to use based on which is easiest to read and write, however, if your data tree is large enough and you need control over what fields get copied, inclusive style is probably a better choice than exclusive style. 81 | 82 | ### Configuration 83 | 84 | Please note that these examples are only loose approximations of real world scenarios and may not be particularly realistic, they are only for the purpose of demonstrating feature usage. 85 | 86 | #### Indiscriminate Style 87 | 88 | This is the most basic usage case and will simply enable the copying of any known associations. 89 | 90 | If you have some models for a blog about like this: 91 | 92 | ```ruby 93 | class Post < ActiveRecord::Base 94 | has_many :comments 95 | end 96 | 97 | class Comment < ActiveRecord::Base 98 | belongs_to :post 99 | end 100 | ``` 101 | 102 | simply add the amoeba configuration block to your model and call the enable method to enable the copying of child records, like this: 103 | 104 | ```ruby 105 | class Post < ActiveRecord::Base 106 | has_many :comments 107 | 108 | amoeba do 109 | enable 110 | end 111 | end 112 | 113 | class Comment < ActiveRecord::Base 114 | belongs_to :post 115 | end 116 | ``` 117 | 118 | Child records will be automatically copied when you run the `amoeba_dup` method. 119 | 120 | #### Inclusive Style 121 | 122 | If you only want some of the associations copied but not others, you may use the inclusive style: 123 | 124 | ```ruby 125 | class Post < ActiveRecord::Base 126 | has_many :comments 127 | has_many :tags 128 | has_many :authors 129 | 130 | amoeba do 131 | enable 132 | include_association :tags 133 | include_association :authors 134 | end 135 | end 136 | 137 | class Comment < ActiveRecord::Base 138 | belongs_to :post 139 | end 140 | ``` 141 | 142 | Using the inclusive style within the amoeba block actually implies that you wish to enable amoeba, so there is no need to run the enable method, though it won't hurt either: 143 | 144 | ```ruby 145 | class Post < ActiveRecord::Base 146 | has_many :comments 147 | has_many :tags 148 | has_many :authors 149 | 150 | amoeba do 151 | include_association :tags 152 | include_association :authors 153 | end 154 | end 155 | 156 | class Comment < ActiveRecord::Base 157 | belongs_to :post 158 | end 159 | ``` 160 | 161 | You may also specify fields to be copied by passing an array. If you call the `include_association` with a single value, it will be appended to the list of already included fields. If you pass an array, your array will overwrite the original values. 162 | 163 | ```ruby 164 | class Post < ActiveRecord::Base 165 | has_many :comments 166 | has_many :tags 167 | has_many :authors 168 | 169 | amoeba do 170 | include_association [:tags, :authors] 171 | end 172 | end 173 | 174 | class Comment < ActiveRecord::Base 175 | belongs_to :post 176 | end 177 | ``` 178 | 179 | These examples will copy the post's tags and authors but not its comments. 180 | 181 | The inclusive style, when used, will automatically disable any other style that was previously selected. 182 | 183 | #### Exclusive Style 184 | 185 | If you have more fields to include than to exclude, you may wish to shorten the amount of typing and reading you need to do by using the exclusive style. All fields that are not explicitly excluded will be copied: 186 | 187 | ```ruby 188 | class Post < ActiveRecord::Base 189 | has_many :comments 190 | has_many :tags 191 | has_many :authors 192 | 193 | amoeba do 194 | exclude_association :comments 195 | end 196 | end 197 | 198 | class Comment < ActiveRecord::Base 199 | belongs_to :post 200 | end 201 | ``` 202 | 203 | This example does the same thing as the inclusive style example, it will copy the post's tags and authors but not its comments. As with inclusive style, there is no need to explicitly enable amoeba when specifying fields to exclude. 204 | 205 | The exclusive style, when used, will automatically disable any other style that was previously selected, so if you selected include fields, and then you choose some exclude fields, the `exclude_association` method will disable the previously selected inclusive style and wipe out any corresponding include fields. 206 | 207 | #### Conditions 208 | 209 | Also if you need to path extra condition for include or exclude relationship you can path method name to `:if` option. 210 | 211 | ```ruby 212 | class Post < ActiveRecord::Base 213 | has_many :comments 214 | has_many :tags 215 | 216 | amoeba do 217 | include_association :comments, if: :popular? 218 | end 219 | 220 | def popular? 221 | likes > 15 222 | end 223 | end 224 | ``` 225 | 226 | After call `Post.first.amoeba_dup` if `likes` is larger 15 than all comments will be duplicated too, but in another situation - no relations will be cloned. Same behavior will be for `exclude_association`. 227 | 228 | **Be aware**! If you wrote: 229 | ```ruby 230 | class Post < ActiveRecord::Base 231 | has_many :comments 232 | has_many :tags 233 | 234 | amoeba do 235 | exclude_association :tags 236 | include_association :comments, if: :popular? 237 | end 238 | 239 | def popular? 240 | likes > 15 241 | end 242 | end 243 | ``` 244 | inclusion strategy will be chosen regardless of the result of `popular?` method call (the same for reverse situation). 245 | 246 | #### Cloning 247 | 248 | If you are using a Many-to-Many relationship, you may tell amoeba to actually make duplicates of the original related records rather than merely maintaining association with the original records. Cloning is easy, merely tell amoeba which fields to clone in the same way you tell it which fields to include or exclude. 249 | 250 | ```ruby 251 | class Post < ActiveRecord::Base 252 | has_and_belongs_to_many :warnings 253 | 254 | has_many :post_widgets 255 | has_many :widgets, :through => :post_widgets 256 | 257 | amoeba do 258 | enable 259 | clone [:widgets, :warnings] 260 | end 261 | end 262 | 263 | class Warning < ActiveRecord::Base 264 | has_and_belongs_to_many :posts 265 | end 266 | 267 | class PostWidget < ActiveRecord::Base 268 | belongs_to :widget 269 | belongs_to :post 270 | end 271 | 272 | class Widget < ActiveRecord::Base 273 | has_many :post_widgets 274 | has_many :posts, :through => :post_widgets 275 | end 276 | ``` 277 | 278 | This example will actually duplicate the warnings and widgets in the database. If there were originally 3 warnings in the database then, upon duplicating a post, you will end up with 6 warnings in the database. This is in contrast to the default behavior where your new post would merely be re-associated with any previously existing warnings and those warnings themselves would not be duplicated. 279 | 280 | #### Limiting Association Types 281 | 282 | By default, amoeba recognizes and attempts to copy any children of the following association types: 283 | 284 | - has one 285 | - has many 286 | - has and belongs to many 287 | 288 | You may control which association types amoeba applies itself to by using the `recognize` method within the amoeba configuration block. 289 | 290 | ```ruby 291 | class Post < ActiveRecord::Base 292 | has_one :config 293 | has_many :comments 294 | has_and_belongs_to_many :tags 295 | 296 | amoeba do 297 | recognize [:has_one, :has_and_belongs_to_many] 298 | end 299 | end 300 | 301 | class Comment < ActiveRecord::Base 302 | belongs_to :post 303 | end 304 | 305 | class Tag < ActiveRecord::Base 306 | has_and_belongs_to_many :posts 307 | end 308 | ``` 309 | 310 | This example will copy the post's configuration data and keep tags associated with the new post, but will not copy the post's comments because amoeba will only recognize and copy children of `has_one` and `has_and_belongs_to_many` associations and in this example, comments are not an `has_and_belongs_to_many` association. 311 | 312 | ### Field Preprocessors 313 | 314 | #### Nullify 315 | 316 | If you wish to prevent a regular (non `has_*` association based) field from retaining it's value when copied, you may "zero out" or "nullify" the field, like this: 317 | 318 | ```ruby 319 | class Topic < ActiveRecord::Base 320 | has_many :posts 321 | end 322 | 323 | class Post < ActiveRecord::Base 324 | belongs_to :topic 325 | has_many :comments 326 | 327 | amoeba do 328 | enable 329 | nullify :date_published 330 | nullify :topic_id 331 | end 332 | end 333 | 334 | class Comment < ActiveRecord::Base 335 | belongs_to :post 336 | end 337 | ``` 338 | 339 | This example will copy all of a post's comments. It will also nullify the publishing date and dissociate the post from its original topic. 340 | 341 | Unlike inclusive and exclusive styles, specifying null fields will not automatically enable amoeba to copy all child records. As with any active record object, the default field value will be used instead of `nil` if a default value exists on the migration. 342 | 343 | #### Set 344 | 345 | If you wish to just set a field to an arbitrary value on all duplicated objects you may use the `set` directive. For example, if you wanted to copy an object that has some kind of approval process associated with it, you likely may wish to set the new object's state to be open or "in progress" again. 346 | 347 | ```ruby 348 | class Post < ActiveRecord::Base 349 | amoeba do 350 | set :state_tracker => "open_for_editing" 351 | end 352 | end 353 | ``` 354 | 355 | In this example, when a post is duplicated, it's `state_tracker` field will always be given a value of `open_for_editing` to start. 356 | 357 | #### Prepend 358 | 359 | You may add a string to the beginning of a copied object's field during the copy phase: 360 | 361 | ```ruby 362 | class Post < ActiveRecord::Base 363 | amoeba do 364 | enable 365 | prepend :title => "Copy of " 366 | end 367 | end 368 | ``` 369 | 370 | #### Append 371 | 372 | You may add a string to the end of a copied object's field during the copy phase: 373 | 374 | ```ruby 375 | class Post < ActiveRecord::Base 376 | amoeba do 377 | enable 378 | append :title => "Copy of " 379 | end 380 | end 381 | ``` 382 | 383 | #### Regex 384 | 385 | You may run a search and replace query on a copied object's field during the copy phase: 386 | 387 | ```ruby 388 | class Post < ActiveRecord::Base 389 | amoeba do 390 | enable 391 | regex :contents => {:replace => /dog/, :with => 'cat'} 392 | end 393 | end 394 | ``` 395 | 396 | #### Custom Methods 397 | 398 | ##### Customize 399 | 400 | You may run a custom method or methods to do basically anything you like, simply pass a lambda block, or an array of lambda blocks to the `customize` directive. Each block must have the same form, meaning that each block must accept two parameters, the original object and the newly copied object. You may then do whatever you wish, like this: 401 | 402 | ```ruby 403 | class Post < ActiveRecord::Base 404 | amoeba do 405 | prepend :title => "Hello world! " 406 | 407 | customize(lambda { |original_post,new_post| 408 | if original_post.foo == "bar" 409 | new_post.baz = "qux" 410 | end 411 | }) 412 | 413 | append :comments => "... know what I'm sayin?" 414 | end 415 | end 416 | ``` 417 | 418 | or this, using an array: 419 | 420 | ```ruby 421 | class Post < ActiveRecord::Base 422 | has_and_belongs_to_many :tags 423 | 424 | amoeba do 425 | include_association :tags 426 | 427 | customize([ 428 | lambda do |orig_obj,copy_of_obj| 429 | # good stuff goes here 430 | end, 431 | 432 | lambda do |orig_obj,copy_of_obj| 433 | # more good stuff goes here 434 | end 435 | ]) 436 | end 437 | end 438 | ``` 439 | 440 | ##### Override 441 | 442 | Lambda blocks passed to customize run, by default, after all copying and field pre-processing. If you wish to run a method before any customization or field pre-processing, you may use `override` the cousin of `customize`. Usage is the same as above. 443 | 444 | ```ruby 445 | class Post < ActiveRecord::Base 446 | amoeba do 447 | prepend :title => "Hello world! " 448 | 449 | override(lambda { |original_post,new_post| 450 | if original_post.foo == "bar" 451 | new_post.baz = "qux" 452 | end 453 | }) 454 | 455 | append :comments => "... know what I'm sayin?" 456 | end 457 | end 458 | ``` 459 | 460 | #### Chaining 461 | 462 | You may apply a single preprocessor to multiple fields at once. 463 | 464 | ```ruby 465 | class Post < ActiveRecord::Base 466 | amoeba do 467 | enable 468 | prepend :title => "Copy of ", :contents => "Copied contents: " 469 | end 470 | end 471 | ``` 472 | 473 | #### Stacking 474 | 475 | You may apply multiple preprocessing directives to a single model at once. 476 | 477 | ```ruby 478 | class Post < ActiveRecord::Base 479 | amoeba do 480 | prepend :title => "Copy of ", :contents => "Original contents: " 481 | append :contents => " (copied version)" 482 | regex :contents => {:replace => /dog/, :with => 'cat'} 483 | end 484 | end 485 | ``` 486 | 487 | This example should result in something like this: 488 | 489 | ```ruby 490 | post = Post.create( 491 | :title => "Hello world", 492 | :contents => "I like dogs, dogs are awesome." 493 | ) 494 | 495 | new_post = post.amoeba_dup 496 | 497 | new_post.title # "Copy of Hello world" 498 | new_post.contents # "Original contents: I like cats, cats are awesome. (copied version)" 499 | ``` 500 | 501 | Like `nullify`, the preprocessing directives do not automatically enable the copying of associated child records. If only preprocessing directives are used and you do want to copy child records and no `include_association` or `exclude_association` list is provided, you must still explicitly enable the copying of child records by calling the enable method from within the amoeba block on your model. 502 | 503 | ### Precedence 504 | 505 | You may use a combination of configuration methods within each model's amoeba block. Recognized association types take precedence over inclusion or exclusion lists. Inclusive style takes precedence over exclusive style, and these two explicit styles take precedence over the indiscriminate style. In other words, if you list fields to copy, amoeba will only copy the fields you list, or only copy the fields you don't exclude as the case may be. Additionally, if a field type is not recognized it will not be copied, regardless of whether it appears in an inclusion list. If you want amoeba to automatically copy all of your child records, do not list any fields using either `include_association` or `exclude_association`. 506 | 507 | The following example syntax is perfectly valid, and will result in the usage of inclusive style. The order in which you call the configuration methods within the amoeba block does not matter: 508 | 509 | ```ruby 510 | class Topic < ActiveRecord::Base 511 | has_many :posts 512 | end 513 | 514 | class Post < ActiveRecord::Base 515 | belongs_to :topic 516 | has_many :comments 517 | has_many :tags 518 | has_many :authors 519 | 520 | amoeba do 521 | exclude_association :authors 522 | include_association :tags 523 | nullify :date_published 524 | prepend :title => "Copy of " 525 | append :contents => " (copied version)" 526 | regex :contents => {:replace => /dog/, :with => 'cat'} 527 | include_association :authors 528 | enable 529 | nullify :topic_id 530 | end 531 | end 532 | 533 | class Comment < ActiveRecord::Base 534 | belongs_to :post 535 | end 536 | ``` 537 | 538 | This example will copy all of a post's tags and authors, but not its comments. It will also nullify the publishing date and dissociate the post from its original topic. It will also preprocess the post's fields as in the previous preprocessing example. 539 | 540 | Note that, because of precedence, inclusive style is used and the list of exclude fields is never consulted. Additionally, the `enable` method is redundant because amoeba is automatically enabled when using `include_association`. 541 | 542 | The preprocessing directives are run after child records are copied and are run in this order. 543 | 544 | 1. Null fields 545 | 2. Prepends 546 | 3. Appends 547 | 4. Search and Replace 548 | 549 | Preprocessing directives do not affect inclusion and exclusion lists. 550 | 551 | ### Recursing 552 | 553 | You may cause amoeba to keep copying down the chain as far as you like, simply add amoeba blocks to each model you wish to have copy its children. Amoeba will automatically recurse into any enabled grandchildren and copy them as well. 554 | 555 | ```ruby 556 | class Post < ActiveRecord::Base 557 | has_many :comments 558 | 559 | amoeba do 560 | enable 561 | end 562 | end 563 | 564 | class Comment < ActiveRecord::Base 565 | belongs_to :post 566 | has_many :ratings 567 | 568 | amoeba do 569 | enable 570 | end 571 | end 572 | 573 | class Rating < ActiveRecord::Base 574 | belongs_to :comment 575 | end 576 | ``` 577 | 578 | In this example, when a post is copied, amoeba will copy each all of a post's comments and will also copy each comment's ratings. 579 | 580 | ### Has One Through 581 | 582 | Using the `has_one :through` association is simple, just be sure to enable amoeba on the each model with a `has_one` association and amoeba will automatically and recursively drill down, like so: 583 | 584 | ```ruby 585 | class Supplier < ActiveRecord::Base 586 | has_one :account 587 | has_one :history, :through => :account 588 | 589 | amoeba do 590 | enable 591 | end 592 | end 593 | 594 | class Account < ActiveRecord::Base 595 | belongs_to :supplier 596 | has_one :history 597 | 598 | amoeba do 599 | enable 600 | end 601 | end 602 | 603 | class History < ActiveRecord::Base 604 | belongs_to :account 605 | end 606 | ``` 607 | 608 | ### Has Many Through 609 | 610 | Copying of `has_many :through` associations works automatically. They perform the copy in the same way as the `has_and_belongs_to_many` association, meaning the actual child records are not copied, but rather the associations are simply maintained. You can add some field preprocessors to the middle model if you like but this is not strictly necessary: 611 | 612 | ```ruby 613 | class Assembly < ActiveRecord::Base 614 | has_many :manifests 615 | has_many :parts, :through => :manifests 616 | 617 | amoeba do 618 | enable 619 | end 620 | end 621 | 622 | class Manifest < ActiveRecord::Base 623 | belongs_to :assembly 624 | belongs_to :part 625 | 626 | amoeba do 627 | prepend :notes => "Copy of " 628 | end 629 | end 630 | 631 | class Part < ActiveRecord::Base 632 | has_many :manifests 633 | has_many :assemblies, :through => :manifests 634 | 635 | amoeba do 636 | enable 637 | end 638 | end 639 | ``` 640 | 641 | ### On The Fly Configuration 642 | 643 | You may control how amoeba copies your object, on the fly, by passing a configuration block to the model's amoeba method. The configuration method is static but the configuration is applied on a per instance basis. 644 | 645 | ```ruby 646 | class Post < ActiveRecord::Base 647 | has_many :comments 648 | 649 | amoeba do 650 | enable 651 | prepend :title => "Copy of " 652 | end 653 | end 654 | 655 | class Comment < ActiveRecord::Base 656 | belongs_to :post 657 | end 658 | 659 | class PostsController < ActionController 660 | def duplicate_a_post 661 | old_post = Post.create( 662 | :title => "Hello world", 663 | :contents => "Lorum ipsum" 664 | ) 665 | 666 | old_post.class.amoeba do 667 | prepend :contents => "Here's a copy: " 668 | end 669 | 670 | new_post = old_post.amoeba_dup 671 | 672 | new_post.title # should be "Copy of Hello world" 673 | new_post.contents # should be "Here's a copy: Lorum ipsum" 674 | new_post.save 675 | end 676 | end 677 | ``` 678 | 679 | ### Inheritance 680 | 681 | If you are using the Single Table Inheritance provided by ActiveRecord, you may cause amoeba to automatically process child classes in the same way as their parents. All you need to do is call the `propagate` method within the amoeba block of the parent class and all child classes should copy in a similar manner. 682 | 683 | ```ruby 684 | create_table :products, :force => true do |t| 685 | t.string :type # this is the STI column 686 | 687 | # these belong to all products 688 | t.string :title 689 | t.decimal :price 690 | 691 | # these are for shirts only 692 | t.decimal :sleeve_length 693 | t.decimal :collar_size 694 | 695 | # these are for computers only 696 | t.integer :ram_size 697 | t.integer :hard_drive_size 698 | end 699 | 700 | class Product < ActiveRecord::Base 701 | has_many :images 702 | has_and_belongs_to_many :categories 703 | 704 | amoeba do 705 | enable 706 | propagate 707 | end 708 | end 709 | 710 | class Shirt < Product 711 | end 712 | 713 | class Computer < Product 714 | end 715 | 716 | class ProductsController 717 | def some_method 718 | my_shirt = Shirt.find(1) 719 | my_shirt.amoeba_dup 720 | my_shirt.save 721 | 722 | # this shirt should now: 723 | # - have its own copy of all parent images 724 | # - be in the same categories as the parent 725 | end 726 | end 727 | ``` 728 | 729 | This example should duplicate all the images and sections associated with this Shirt, which is a child of Product 730 | 731 | #### Parenting Style 732 | 733 | By default, propagation uses submissive parenting, meaning the config settings on the parent will be applied, but any child settings, if present, will either add to or overwrite the parent settings depending on how you call the DSL methods. 734 | 735 | You may change this behavior, the so called "parenting style", to give preference to the parent settings or to ignore any and all child settings. 736 | 737 | ##### Relaxed Parenting 738 | 739 | The `:relaxed` parenting style will prefer parent settings. 740 | 741 | ```ruby 742 | class Product < ActiveRecord::Base 743 | has_many :images 744 | has_and_belongs_to_many :sections 745 | 746 | amoeba do 747 | exclude_association :images 748 | propagate :relaxed 749 | end 750 | end 751 | 752 | class Shirt < Product 753 | include_association :images 754 | include_association :sections 755 | prepend :title => "Copy of " 756 | end 757 | ``` 758 | 759 | In this example, the conflicting `include_association` settings on the child will be ignored and the parent `exclude_association` setting will be used, while the `prepend` setting on the child will be honored because it doesn't conflict with the parent. 760 | 761 | ##### Strict Parenting 762 | 763 | The `:strict` style will ignore child settings altogether and inherit any parent settings. 764 | 765 | ```ruby 766 | class Product < ActiveRecord::Base 767 | has_many :images 768 | has_and_belongs_to_many :sections 769 | 770 | amoeba do 771 | exclude_association :images 772 | propagate :strict 773 | end 774 | end 775 | 776 | class Shirt < Product 777 | include_association :images 778 | include_association :sections 779 | prepend :title => "Copy of " 780 | end 781 | ``` 782 | 783 | In this example, the only processing that will happen when a Shirt is duplicated is whatever processing is allowed by the parent. So in this case the parent's `exclude_association` directive takes precedence over the child's `include_association` settings, and not only that, but none of the other settings for the child are used either. The `prepend` setting of the child is completely ignored. 784 | 785 | ##### Parenting and Precedence 786 | 787 | Because of the two general forms of DSL config parameter usage, you may wish to make yourself mindful of how your coding style will affect the outcome of duplicating an object. 788 | 789 | Just remember that: 790 | 791 | * If you pass an array you will wipe all previous settings 792 | * If you pass single values, you will add to currently existing settings 793 | 794 | This means that, for example: 795 | 796 | * When using the submissive parenting style, you can child take full precedence on a per field basis by passing an array of config values. This will cause the setting from the parent to be overridden instead of added to. 797 | * When using the relaxed parenting style, you can still let the parent take precedence on a per field basis by passing an array of config values. This will cause the setting for that child to be overridden instead of added to. 798 | 799 | ##### A Submissive Override Example 800 | 801 | This version will use both the parent and child settings, so both the images and sections will be copied. 802 | 803 | ```ruby 804 | class Product < ActiveRecord::Base 805 | has_many :images 806 | has_and_belongs_to_many :sections 807 | 808 | amoeba do 809 | include_association :images 810 | propagate 811 | end 812 | end 813 | 814 | class Shirt < Product 815 | include_association :sections 816 | end 817 | ``` 818 | 819 | The next version will use only the child settings because passing an array will override any previous settings rather than adding to them and the child config takes precedence in the `submissive` parenting style. So in this case only the sections will be copied. 820 | 821 | ```ruby 822 | class Product < ActiveRecord::Base 823 | has_many :images 824 | has_and_belongs_to_many :sections 825 | 826 | amoeba do 827 | include_association :images 828 | propagate 829 | end 830 | end 831 | 832 | class Shirt < Product 833 | include_association [:sections] 834 | end 835 | ``` 836 | 837 | ##### A Relaxed Override Example 838 | 839 | This version will use both the parent and child settings, so both the images and sections will be copied. 840 | 841 | ```ruby 842 | class Product < ActiveRecord::Base 843 | has_many :images 844 | has_and_belongs_to_many :sections 845 | 846 | amoeba do 847 | include_association :images 848 | propagate :relaxed 849 | end 850 | end 851 | 852 | class Shirt < Product 853 | include_association :sections 854 | end 855 | ``` 856 | 857 | The next version will use only the parent settings because passing an array will override any previous settings rather than adding to them and the parent config takes precedence in the `relaxed` parenting style. So in this case only the images will be copied. 858 | 859 | ```ruby 860 | class Product < ActiveRecord::Base 861 | has_many :images 862 | has_and_belongs_to_many :sections 863 | 864 | amoeba do 865 | include_association [:images] 866 | propagate 867 | end 868 | end 869 | 870 | class Shirt < Product 871 | include_association :sections 872 | end 873 | ``` 874 | 875 | ### Validating Nested Attributes 876 | 877 | If you end up with some validation issues when trying to validate the presence of a child's `belongs_to` association, just be sure to include the `:inverse_of` declaration on your relationships and all should be well. 878 | 879 | For example this will throw a validation error saying that your posts are invalid: 880 | 881 | ```ruby 882 | class Author < ActiveRecord::Base 883 | has_many :posts 884 | 885 | amoeba do 886 | enable 887 | end 888 | end 889 | 890 | class Post < ActiveRecord::Base 891 | belongs_to :author 892 | validates_presence_of :author 893 | 894 | amoeba do 895 | enable 896 | end 897 | end 898 | 899 | author = Author.find(1) 900 | author.amoeba_dup 901 | 902 | author.save # this will fail validation 903 | ``` 904 | 905 | Where this will work fine: 906 | 907 | ```ruby 908 | class Author < ActiveRecord::Base 909 | has_many :posts, :inverse_of => :author 910 | 911 | amoeba do 912 | enable 913 | end 914 | end 915 | 916 | class Post < ActiveRecord::Base 917 | belongs_to :author, :inverse_of => :posts 918 | validates_presence_of :author 919 | 920 | amoeba do 921 | enable 922 | end 923 | end 924 | 925 | author = Author.find(1) 926 | author.amoeba_dup 927 | 928 | author.save # this will pass validation 929 | ``` 930 | 931 | This issue is not amoeba specific and also occurs when creating new objects using `accepts_nested_attributes_for`, like this: 932 | 933 | ```ruby 934 | class Author < ActiveRecord::Base 935 | has_many :posts 936 | accepts_nested_attributes_for :posts 937 | end 938 | 939 | class Post < ActiveRecord::Base 940 | belongs_to :author 941 | validates_presence_of :author 942 | end 943 | 944 | # this will fail validation 945 | author = Author.create({:name => "Jim Smith", :posts => [{:title => "Hello World", :contents => "Lorum ipsum dolor}]}) 946 | ``` 947 | 948 | This issue with `accepts_nested_attributes_for` can also be solved by using `:inverse_of`, like this: 949 | 950 | ```ruby 951 | class Author < ActiveRecord::Base 952 | has_many :posts, :inverse_of => :author 953 | accepts_nested_attributes_for :posts 954 | end 955 | 956 | class Post < ActiveRecord::Base 957 | belongs_to :author, :inverse_of => :posts 958 | validates_presence_of :author 959 | end 960 | 961 | # this will pass validation 962 | author = Author.create({:name => "Jim Smith", :posts => [{:title => "Hello World", :contents => "Lorum ipsum dolor}]}) 963 | ``` 964 | 965 | The crux of the issue is that upon duplication, the new `Author` instance does not yet have an ID because it has not yet been persisted, so the `:posts` do not yet have an `:author_id` either, and thus no `:author` and thus they will fail validation. This issue may likely affect amoeba usage so if you get some validation failures, be sure to add `:inverse_of` to your models. 966 | 967 | 968 | ## Cloning using custom method 969 | 970 | If you need to clone model with custom method you can use `through`: 971 | 972 | ```ruby 973 | class ChildPrototype < ActiveRecord::Base 974 | amoeba do 975 | through :become_child 976 | end 977 | 978 | def become_child 979 | self.dup.becomes(Child) 980 | end 981 | end 982 | 983 | class Child < ChildPrototype 984 | end 985 | ``` 986 | 987 | After cloning we will get instance of `Child` instead of `ChildPrototype` 988 | 989 | ## Remapping associations 990 | 991 | If you will need to do complex cloning with remapping associations name you can use `remapper`: 992 | 993 | ```ruby 994 | class ObjectPrototype < ActiveRecord::Base 995 | has_many :child_prototypes 996 | 997 | amoeba do 998 | method :become_real 999 | remapper :remap_associations 1000 | end 1001 | 1002 | def become_real 1003 | self.dup().becomes( RealObject ) 1004 | end 1005 | 1006 | def remap_associations( name ) 1007 | :childs if name == :child_prototypes 1008 | end 1009 | end 1010 | 1011 | class RealObject < ObjectPrototype 1012 | has_many :childs 1013 | end 1014 | 1015 | class ChildPrototype < ActiveRecord::Base 1016 | amoeba do 1017 | method :become_child 1018 | end 1019 | 1020 | def become_child 1021 | self.dup().becomes( Child ) 1022 | end 1023 | end 1024 | 1025 | class Child < ChildPrototype 1026 | end 1027 | ``` 1028 | 1029 | In result we will get next: 1030 | 1031 | ```ruby 1032 | prototype = ObjectPrototype.new 1033 | prototype.child_prototypes << ChildPrototype.new 1034 | object = prototype.amoeba_dup 1035 | object.class # => RealObject 1036 | object.childs.first.class #=> Child 1037 | ``` 1038 | 1039 | ## Configuration Reference 1040 | 1041 | Here is a static reference to the available configuration methods, usable within the amoeba block on your rails models. 1042 | 1043 | ### through 1044 | 1045 | Set method what we will use for cloning model instead of `dup`. 1046 | 1047 | for example: 1048 | 1049 | ```ruby 1050 | amoeba do 1051 | through :supper_pupper_dup 1052 | end 1053 | 1054 | def supper_pupper_dup 1055 | puts "multiplied by budding" 1056 | self.dup 1057 | end 1058 | ``` 1059 | 1060 | ### Controlling Associations 1061 | 1062 | #### enable 1063 | 1064 | Enables amoeba in the default style of copying all known associated child records. Using the enable method is only required if you wish to enable amoeba but you are not using either the `include_association` or `exclude_association` directives. If you use either inclusive or exclusive style, amoeba is automatically enabled for you, so calling `enable` would be redundant, though it won't hurt. 1065 | 1066 | #### include_association 1067 | 1068 | Adds a field to the list of fields which should be copied. All associations not in this list will not be copied. This method may be called multiple times, once per desired field, or you may pass an array of field names. Passing a single symbol will add to the list of included fields. Passing an array will empty the list and replace it with the array you pass. 1069 | 1070 | #### exclude_association 1071 | 1072 | Adds a field to the list of fields which should not be copied. Only the associations that are not in this list will be copied. This method may be called multiple times, once per desired field, or you may pass an array of field names. Passing a single symbol will add to the list of excluded fields. Passing an array will empty the list and replace it with the array you pass. 1073 | 1074 | #### clone 1075 | 1076 | Adds a field to the list of associations which should have their associated children actually cloned. This means for example, that instead of just maintaining original associations with previously existing tags, a copy will be made of each tag, and the new record will be associated with these new tag copies rather than the old tag copies. This method may be called multiple times, once per desired field, or you may pass an array of field names. Passing a single symbol will add to the list of excluded fields. Passing an array will empty the list and replace it with the array you pass. 1077 | 1078 | #### propagate 1079 | 1080 | This causes any inherited child models to take the same config settings when copied. This method may take up to one argument to control the so called "parenting style". The argument should be one of `strict`, `relaxed` or `submissive`. 1081 | 1082 | The default "parenting style" is `submissive` 1083 | 1084 | for example 1085 | 1086 | ```ruby 1087 | amoeba do 1088 | propagate :strict 1089 | end 1090 | ``` 1091 | 1092 | will choose the strict parenting style of inherited settings. 1093 | 1094 | #### raised 1095 | 1096 | This causes any child to behave with a (potentially) different "parenting style" than its actual parent. This method takes up to a single parameter for which there are three options, `strict`, `relaxed` and `submissive`. 1097 | 1098 | The default "parenting style" is `submissive` 1099 | 1100 | for example: 1101 | 1102 | ```ruby 1103 | amoeba do 1104 | raised :relaxed 1105 | end 1106 | ``` 1107 | 1108 | will choose the relaxed parenting style of inherited settings for this child. A parenting style set via the `raised` method takes precedence over the parenting style set using the `propagate` method. 1109 | 1110 | #### remapper 1111 | 1112 | Set the method what will be used for remapping of association name. Method will have one argument - association name as Symbol. If method will return nil then association will not be remapped. 1113 | 1114 | for example: 1115 | 1116 | ```ruby 1117 | amoeba do 1118 | remapper :childs_to_parents 1119 | end 1120 | 1121 | def childs_to_parents(association_name) 1122 | :parents if association_name == :childs 1123 | end 1124 | ``` 1125 | 1126 | ### Pre-Processing Fields 1127 | 1128 | #### nullify 1129 | 1130 | Adds a field to the list of non-association based fields which should be set to nil during copy. All fields in this list will be set to `nil` - note that any nullified field will be given its default value if a default value exists on this model's migration. This method may be called multiple times, once per desired field, or you may pass an array of field names. Passing a single symbol will add to the list of null fields. Passing an array will empty the list and replace it with the array you pass. 1131 | 1132 | #### prepend 1133 | 1134 | Prefix a field with some text. This only works for string fields. Accepts a hash of fields to prepend. The keys are the field names and the values are the prefix strings. An example scenario would be to add a string such as "Copy of " to your title field. Don't forget to include extra space to the right if you want it. Passing a hash will add each key value pair to the list of prepend directives. If you wish to empty the list of directives, you may pass the hash inside of an array like this `[{:title => "Copy of "}]`. 1135 | 1136 | #### append 1137 | 1138 | Append some text to a field. This only works for string fields. Accepts a hash of fields to append. The keys are the field names and the values are the prefix strings. An example would be to add " (copied version)" to your description field. Don't forget to add a leading space if you want it. Passing a hash will add each key value pair to the list of append directives. If you wish to empty the list of directives, you may pass the hash inside of an array like this `[{:contents => " (copied version)"}]`. 1139 | 1140 | #### set 1141 | 1142 | Set a field to a given value. This should work for almost any type of field. Accepts a hash of fields and the values you want them set to.. The keys are the field names and the values are the prefix strings. An example would be to add " (copied version)" to your description field. Don't forget to add a leading space if you want it. Passing a hash will add each key value pair to the list of append directives. If you wish to empty the list of directives, you may pass the hash inside of an array like this `[{:approval_state => "open_for_editing"}]`. 1143 | 1144 | #### regex 1145 | 1146 | Globally search and replace the field for a given pattern. Accepts a hash of fields to run search and replace upon. The keys are the field names and the values are each a hash with information about what to find and what to replace it with. in the form of . An example would be to replace all occurrences of the word "dog" with the word "cat", the parameter hash would look like this `:contents => {:replace => /dog/, :with => "cat"}`. Passing a hash will add each key value pair to the list of regex directives. If you wish to empty the list of directives, you may pass the hash inside of an array like this `[{:contents => {:replace => /dog/, :with => "cat"}]`. 1147 | 1148 | #### override 1149 | 1150 | Runs a custom method so you can do basically whatever you want. All you need to do is pass a lambda block or an array of lambda blocks that take two parameters, the original object and the new object copy. These blocks will run before any other duplication or field processing. 1151 | 1152 | This method may be called multiple times, once per desired customizer block, or you may pass an array of lambdas. Passing a single lambda will add to the list of processing directives. Passing an array will empty the list and replace it with the array you pass. 1153 | 1154 | #### customize 1155 | 1156 | Runs a custom method so you can do basically whatever you want. All you need to do is pass a lambda block or an array of lambda blocks that take two parameters, the original object and the new object copy. These blocks will run after all copying and field processing. 1157 | 1158 | This method may be called multiple times, once per desired customizer block, or you may pass an array of lambdas. Passing a single lambda will add to the list of processing directives. Passing an array will empty the list and replace it with the array you pass. 1159 | 1160 | ## Known Limitations and Issues 1161 | 1162 | The regular expression preprocessor uses case-sensitive `String#gsub`. Given the performance decreases inherrent in using regular expressions already, the fact that character classes can essentially account for case-insensitive searches, the desire to keep the DSL simple and the general use cases for this gem, I don't see a good reason to add yet more decision based conditional syntax to accommodate using case-insensitive searches or singular replacements with `String#sub`. If you find yourself wanting either of these features, by all means fork the code base and if you like your changes, submit a pull request. 1163 | 1164 | The behavior when copying nested hierarchical models is undefined. Copying a category model which has a `parent_id` field pointing to the parent category, for example, is currently undefined. 1165 | 1166 | The behavior when copying polymorphic `has_many` associations is also undefined. Support for these types of associations is planned for a future release. 1167 | 1168 | ## For Developers 1169 | 1170 | You may run the rspec tests like this: 1171 | 1172 | ```sh 1173 | bundle exec rspec spec 1174 | ``` 1175 | 1176 | ### TODO 1177 | 1178 | * add ability to cancel further processing from within an override block 1179 | * write some spec for the override method 1180 | --------------------------------------------------------------------------------