acts_as_span 0.0.5 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +11 -3
- data/.rspec +2 -0
- data/.tool-versions +1 -0
- data/.travis.yml +12 -0
- data/Gemfile +0 -3
- data/README.rdoc +4 -16
- data/Rakefile +5 -1
- data/acts_as_span.gemspec +31 -14
- data/config/locales/en/acts_as_span.yml +15 -0
- data/lib/acts_as_span.rb +69 -98
- data/lib/acts_as_span/end_date_propagator.rb +198 -0
- data/lib/acts_as_span/no_overlap_validator.rb +86 -0
- data/lib/acts_as_span/span_instance.rb +24 -32
- data/lib/acts_as_span/span_instance/status.rb +12 -27
- data/lib/acts_as_span/span_instance/validations.rb +11 -53
- data/lib/acts_as_span/span_klass.rb +11 -19
- data/lib/acts_as_span/span_klass/status.rb +43 -12
- data/lib/acts_as_span/version.rb +6 -4
- data/lib/acts_as_span/within_parent_date_span_validator.rb +44 -0
- data/spec/lib/acts_as_span_spec.rb +38 -35
- data/spec/lib/delegation_spec.rb +45 -78
- data/spec/lib/end_date_propagator_spec.rb +319 -0
- data/spec/lib/no_overlap_validator_spec.rb +129 -0
- data/spec/lib/span_instance/named_scopes_on_spec.rb +193 -193
- data/spec/lib/span_instance/named_scopes_spec.rb +193 -191
- data/spec/lib/span_instance/overlap_spec.rb +193 -253
- data/spec/lib/span_instance/status_spec.rb +22 -35
- data/spec/lib/span_instance/validations_spec.rb +8 -44
- data/spec/lib/span_instance_spec.rb +17 -30
- data/spec/lib/span_klass/status_spec.rb +38 -0
- data/spec/lib/within_parent_date_span_validator_spec.rb +126 -0
- data/spec/spec_helper.rb +19 -6
- data/spec/spec_models.rb +226 -0
- metadata +167 -61
- data/Gemfile.lock +0 -47
- data/lib/acts_as_span/span_instance/overlap.rb +0 -17
- data/lib/acts_as_span/span_klass/overlap.rb +0 -21
- data/spec/lib/negative_spec.rb +0 -30
- data/spec/spec.opts +0 -1
    
        checksums.yaml
    ADDED
    
    | @@ -0,0 +1,7 @@ | |
| 1 | 
            +
            ---
         | 
| 2 | 
            +
            SHA256:
         | 
| 3 | 
            +
              metadata.gz: 59d6ed6e5f0b02d6d3683a99d62ac5f3b11ccdb5cf510f5bf1a3197e095102ff
         | 
| 4 | 
            +
              data.tar.gz: 856975edeadab3d35def0a65e51bb6d65987fc2042cdec822d72b7c76741a9cb
         | 
| 5 | 
            +
            SHA512:
         | 
| 6 | 
            +
              metadata.gz: cb1ae3e3c092a528e83d7dcb2fd9ca7d601b985eaa974b6eac1c1eb2634dc4630473dc5bfcafd548d3748a602569185cd803c2f7f6551c486dddcee1905e8205
         | 
| 7 | 
            +
              data.tar.gz: 605657a4fb0114f18018b4aab29d9c2f9c63bf87321fe7c04ceaccb7974ad064dc4112ce3b7e5b44c8b541b65680e768e60ef54e8d43e1502ec59f27f46c797d
         | 
    
        data/.gitignore
    CHANGED
    
    
    
        data/.rspec
    ADDED
    
    
    
        data/.tool-versions
    ADDED
    
    | @@ -0,0 +1 @@ | |
| 1 | 
            +
            ruby 2.6.5
         | 
    
        data/.travis.yml
    ADDED
    
    
    
        data/Gemfile
    CHANGED
    
    
    
        data/README.rdoc
    CHANGED
    
    | @@ -1,6 +1,8 @@ | |
| 1 1 | 
             
            = acts_as_span
         | 
| 2 2 |  | 
| 3 | 
            -
            ActiveRecord model w/ a start_date and an end_date
         | 
| 3 | 
            +
            ActiveRecord model w/ a start_date and an end_date == ActsAsSpan
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            Treat those date spans like the objects they are!
         | 
| 4 6 |  | 
| 5 7 | 
             
            == Getting Started
         | 
| 6 8 |  | 
| @@ -14,20 +16,6 @@ In your model: | |
| 14 16 | 
             
                  acts_as_span
         | 
| 15 17 | 
             
                end
         | 
| 16 18 |  | 
| 17 | 
            -
            In your migrations:
         | 
| 18 | 
            -
             | 
| 19 | 
            -
                class AddSpanToSpanRecord < ActiveRecord::Migration
         | 
| 20 | 
            -
                  def self.up
         | 
| 21 | 
            -
                    add_column :span_records, :start_date, :date
         | 
| 22 | 
            -
                    add_column :span_records, :end_date, :date
         | 
| 23 | 
            -
                  end
         | 
| 24 | 
            -
             | 
| 25 | 
            -
                  def self.down
         | 
| 26 | 
            -
                    remove_column :span_records, :start_date
         | 
| 27 | 
            -
                    remove_column :span_records, :end_date
         | 
| 28 | 
            -
                  end
         | 
| 29 | 
            -
                end
         | 
| 30 | 
            -
             | 
| 31 19 | 
             
            == Copyright
         | 
| 32 20 |  | 
| 33 | 
            -
            Copyright (c) 2011 Annkissam. See LICENSE for details.
         | 
| 21 | 
            +
            Copyright (c) 2011-2018 Annkissam. See LICENSE for details.
         | 
    
        data/Rakefile
    CHANGED
    
    
    
        data/acts_as_span.gemspec
    CHANGED
    
    | @@ -1,23 +1,40 @@ | |
| 1 | 
            -
            #  | 
| 2 | 
            -
             | 
| 3 | 
            -
             | 
| 1 | 
            +
            # frozen_string_Literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            $LOAD_PATH.push File.expand_path('lib', __dir__)
         | 
| 4 | 
            +
            require 'acts_as_span/version'
         | 
| 4 5 |  | 
| 5 6 | 
             
            Gem::Specification.new do |s|
         | 
| 6 | 
            -
              s.name        =  | 
| 7 | 
            +
              s.name        = 'acts_as_span'
         | 
| 7 8 | 
             
              s.version     = ActsAsSpan::VERSION::STRING
         | 
| 8 | 
            -
              s.authors     = [ | 
| 9 | 
            -
              s.email       = [ | 
| 10 | 
            -
              s.homepage    =  | 
| 9 | 
            +
              s.authors     = ['Eric Sullivan']
         | 
| 10 | 
            +
              s.email       = ['eric.sullivan@annkissam.com']
         | 
| 11 | 
            +
              s.homepage    = 'https://github.com/annkissam/acts_as_span'
         | 
| 11 12 | 
             
              s.summary     = ActsAsSpan::VERSION::SUMMARY
         | 
| 12 | 
            -
              s.description =  | 
| 13 | 
            +
              s.description = 'ActiveRecord model w/ a start_date and an end_date == ActsAsSpan'
         | 
| 14 | 
            +
              s.license       = 'MIT'
         | 
| 13 15 |  | 
| 14 | 
            -
               | 
| 16 | 
            +
              # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
         | 
| 17 | 
            +
              # to allow pushing to a single host or delete this section to allow pushing to any host.
         | 
| 18 | 
            +
              if s.respond_to?(:metadata)
         | 
| 19 | 
            +
                s.metadata['allowed_push_host'] = 'https://rubygems.org'
         | 
| 20 | 
            +
              else
         | 
| 21 | 
            +
                raise 'RubyGems 2.0 or newer is required to protect against ' \
         | 
| 22 | 
            +
                      'public gem pushes.'
         | 
| 23 | 
            +
              end
         | 
| 15 24 |  | 
| 16 25 | 
             
              s.files         = `git ls-files`.split("\n")
         | 
| 17 26 | 
             
              s.test_files    = `git ls-files -- {test,spec,features}/*`.split("\n")
         | 
| 18 | 
            -
              s.executables   = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
         | 
| 19 | 
            -
              s.require_paths = [ | 
| 20 | 
            -
             | 
| 21 | 
            -
              s. | 
| 22 | 
            -
              s. | 
| 27 | 
            +
              s.executables   = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
         | 
| 28 | 
            +
              s.require_paths = %w[lib]
         | 
| 29 | 
            +
             | 
| 30 | 
            +
              s.add_development_dependency 'bundler', '~> 2.1.4'
         | 
| 31 | 
            +
              s.add_development_dependency 'has_siblings', '~> 0.2.7'
         | 
| 32 | 
            +
              s.add_development_dependency 'pry-byebug'
         | 
| 33 | 
            +
              s.add_development_dependency 'rake', '>= 12.3.3'
         | 
| 34 | 
            +
              s.add_development_dependency 'rspec', '~> 3.0'
         | 
| 35 | 
            +
              s.add_development_dependency 'sqlite3', '~> 1.4'
         | 
| 36 | 
            +
              s.add_development_dependency 'temping'
         | 
| 37 | 
            +
             | 
| 38 | 
            +
              s.add_runtime_dependency('activerecord', '>= 5.0.0')
         | 
| 39 | 
            +
              s.add_runtime_dependency('activesupport', '>= 5.0.0')
         | 
| 23 40 | 
             
            end
         | 
| @@ -0,0 +1,15 @@ | |
| 1 | 
            +
            en:
         | 
| 2 | 
            +
              activerecord:
         | 
| 3 | 
            +
                errors:
         | 
| 4 | 
            +
                  messages:
         | 
| 5 | 
            +
                    end_date_propagator:
         | 
| 6 | 
            +
                      propagation_failure: "%{parent} could not propagate
         | 
| 7 | 
            +
                       %{end_date_field_name} to %{child}:\n%{reason}"
         | 
| 8 | 
            +
                    # TODO: let pluralize handle pluralization
         | 
| 9 | 
            +
                    no_overlap:
         | 
| 10 | 
            +
                      one: "A %{model_name} already exists between
         | 
| 11 | 
            +
                       %{start_date} - %{end_date}: %{overlapping_records_s}"
         | 
| 12 | 
            +
                      other: "%{count} %{model_name_plural} already exist between
         | 
| 13 | 
            +
                       %{start_date} - %{end_date}: %{overlapping_records_s}"
         | 
| 14 | 
            +
                    not_within_parent_date_span: Must exist within the %{parent} date span
         | 
| 15 | 
            +
                    start_date_after_end_date: Must be on or after %{start_field}
         | 
    
        data/lib/acts_as_span.rb
    CHANGED
    
    | @@ -1,161 +1,132 @@ | |
| 1 | 
            -
            require 'active_support'
         | 
| 2 1 | 
             
            require 'ostruct'
         | 
| 3 | 
            -
            require ' | 
| 2 | 
            +
            require 'acts_as_span/version'
         | 
| 3 | 
            +
            require 'acts_as_span/span_klass'
         | 
| 4 | 
            +
            require 'acts_as_span/span_instance'
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            require 'acts_as_span/no_overlap_validator'
         | 
| 7 | 
            +
            require 'acts_as_span/within_parent_date_span_validator'
         | 
| 4 8 |  | 
| 5 | 
            -
             | 
| 9 | 
            +
            require 'acts_as_span/end_date_propagator'
         | 
| 6 10 |  | 
| 7 | 
            -
            require  | 
| 8 | 
            -
            require  | 
| 9 | 
            -
             | 
| 11 | 
            +
            require 'active_support'
         | 
| 12 | 
            +
            require 'active_record'
         | 
| 13 | 
            +
             | 
| 14 | 
            +
            I18n.load_path += Dir[File.join(File.dirname(__dir__), 'config', 'locales', '**', 'acts_as_span.yml')]
         | 
| 10 15 |  | 
| 11 16 | 
             
            module ActsAsSpan
         | 
| 12 17 | 
             
              extend ActiveSupport::Concern
         | 
| 13 | 
            -
             | 
| 18 | 
            +
             | 
| 19 | 
            +
              OPTIONS = %i[start_field end_field name].freeze
         | 
| 20 | 
            +
             | 
| 14 21 | 
             
              class << self
         | 
| 15 22 | 
             
                def options
         | 
| 16 23 | 
             
                  @options ||= {
         | 
| 17 | 
            -
                    : | 
| 18 | 
            -
                    : | 
| 19 | 
            -
                    :start_date_field_required => false,
         | 
| 20 | 
            -
                    :end_date_field_required => false,
         | 
| 21 | 
            -
                    :span_overlap_scope => nil,
         | 
| 22 | 
            -
                    :span_overlap_count => nil,
         | 
| 24 | 
            +
                    :start_field => :start_date,
         | 
| 25 | 
            +
                    :end_field => :end_date,
         | 
| 23 26 | 
             
                    :name => :default
         | 
| 24 27 | 
             
                  }
         | 
| 25 28 | 
             
                end
         | 
| 26 | 
            -
             | 
| 29 | 
            +
             | 
| 27 30 | 
             
                def configure
         | 
| 28 31 | 
             
                  yield(self) if block_given?
         | 
| 29 32 | 
             
                end
         | 
| 30 33 | 
             
              end
         | 
| 31 | 
            -
             | 
| 32 | 
            -
              #by default, all model classess & their instances will return false w/ acts_as_span?
         | 
| 33 | 
            -
              included do
         | 
| 34 | 
            -
                self.send(:extend, ActsAsSpan::NegativeMethods)
         | 
| 35 | 
            -
                self.send(:include, ActsAsSpan::NegativeMethods)
         | 
| 36 | 
            -
              end
         | 
| 37 | 
            -
              
         | 
| 34 | 
            +
             | 
| 38 35 | 
             
              module ClassMethods
         | 
| 39 36 | 
             
                def acts_as_span(*args)
         | 
| 40 | 
            -
                  #this model & its instances will return true w/ acts_as_span?
         | 
| 41 | 
            -
                  self.send(:extend, ActsAsSpan::PositiveMethods)
         | 
| 42 | 
            -
                  self.send(:include, ActsAsSpan::PositiveMethods)
         | 
| 43 | 
            -
                  
         | 
| 44 | 
            -
                  self.send(:extend, Forwardable)
         | 
| 45 | 
            -
                  
         | 
| 46 37 | 
             
                  self.send(:extend, ActsAsSpan::ExtendedClassMethods)
         | 
| 47 38 | 
             
                  self.send(:include, ActsAsSpan::IncludedInstanceMethods)
         | 
| 48 | 
            -
             | 
| 39 | 
            +
             | 
| 40 | 
            +
                  # TODO: There's some refactoring that could be done here using keyword args (or the more standard old hash arg pattern)
         | 
| 49 41 | 
             
                  options = OpenStruct.new(args.last.is_a?(Hash) ? ActsAsSpan.options.merge(args.pop) : ActsAsSpan.options)
         | 
| 50 | 
            -
             | 
| 51 | 
            -
                   | 
| 52 | 
            -
             | 
| 53 | 
            -
                  
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                  unsupported_options =
         | 
| 44 | 
            +
                    options.to_h.keys.reject { |opt| OPTIONS.include? opt }
         | 
| 45 | 
            +
                  unless unsupported_options.empty?
         | 
| 46 | 
            +
                    raise ArgumentError,
         | 
| 47 | 
            +
                      'Unsupported option(s): ' <<
         | 
| 48 | 
            +
                      unsupported_options.map { |o| "'#{o}'" }.join(', ')
         | 
| 49 | 
            +
                  end
         | 
| 50 | 
            +
             | 
| 54 51 | 
             
                  acts_as_span_definitions[options.name] = options
         | 
| 55 | 
            -
             | 
| 56 | 
            -
                   | 
| 57 | 
            -
             | 
| 58 | 
            -
             | 
| 59 | 
            -
             | 
| 60 | 
            -
             | 
| 61 | 
            -
             | 
| 62 | 
            -
             | 
| 63 | 
            -
             | 
| 64 | 
            -
             | 
| 65 | 
            -
             | 
| 66 | 
            -
             | 
| 67 | 
            -
             | 
| 68 | 
            -
             | 
| 69 | 
            -
             | 
| 70 | 
            -
                  
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                  # TODO add tests that check delegation of all methos in span
         | 
| 54 | 
            +
                  delegate :span_status,
         | 
| 55 | 
            +
                           :span_status_on,
         | 
| 56 | 
            +
                           :current?,
         | 
| 57 | 
            +
                           :current_on?,
         | 
| 58 | 
            +
                           :future?,
         | 
| 59 | 
            +
                           :future_on?,
         | 
| 60 | 
            +
                           :expired?,
         | 
| 61 | 
            +
                           :expired_on?,
         | 
| 62 | 
            +
                           :past?,
         | 
| 63 | 
            +
                           :past_on?, to: :span
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                  delegate :acts_as_span_definitions, to: :class
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                  # TODO idem above
         | 
| 71 68 | 
             
                  class << self
         | 
| 72 | 
            -
                     | 
| 73 | 
            -
             | 
| 74 | 
            -
             | 
| 75 | 
            -
             | 
| 76 | 
            -
             | 
| 77 | 
            -
             | 
| 78 | 
            -
             | 
| 79 | 
            -
             | 
| 69 | 
            +
                    delegate :current,
         | 
| 70 | 
            +
                             :current_on,
         | 
| 71 | 
            +
                             :future,
         | 
| 72 | 
            +
                             :future_on,
         | 
| 73 | 
            +
                             :expired,
         | 
| 74 | 
            +
                             :expired_on,
         | 
| 75 | 
            +
                             :past_on,
         | 
| 76 | 
            +
                             :past,
         | 
| 77 | 
            +
                             :current_or_future_on,
         | 
| 78 | 
            +
                             :current_or_future, to: :span
         | 
| 80 79 | 
             
                  end
         | 
| 81 | 
            -
             | 
| 80 | 
            +
             | 
| 82 81 | 
             
                  validate :validate_spans
         | 
| 83 82 | 
             
                end
         | 
| 84 | 
            -
             | 
| 83 | 
            +
             | 
| 85 84 | 
             
                def acts_as_span_definitions
         | 
| 86 85 | 
             
                  @_acts_as_span_definitions ||= {}
         | 
| 87 86 | 
             
                end
         | 
| 88 87 | 
             
              end
         | 
| 89 | 
            -
             | 
| 88 | 
            +
             | 
| 90 89 | 
             
              module ExtendedClassMethods
         | 
| 91 | 
            -
                def overlap(test_record)
         | 
| 92 | 
            -
                  overlap_for(test_record, :default, :default)
         | 
| 93 | 
            -
                end
         | 
| 94 | 
            -
                
         | 
| 95 | 
            -
                def overlap_for(test_record, test_record_span_name = :default, this_span_name = :default)
         | 
| 96 | 
            -
                  span_for(this_span_name).overlap(test_record.span_for(test_record_span_name))
         | 
| 97 | 
            -
                end
         | 
| 98 | 
            -
                
         | 
| 99 90 | 
             
                def spans
         | 
| 100 91 | 
             
                  acts_as_span_definitions.keys.map { |acts_as_span_definition_name| span_for(acts_as_span_definition_name) }
         | 
| 101 92 | 
             
                end
         | 
| 102 | 
            -
             | 
| 93 | 
            +
             | 
| 103 94 | 
             
                def span
         | 
| 104 95 | 
             
                  span_for(:default)
         | 
| 105 96 | 
             
                end
         | 
| 106 | 
            -
             | 
| 97 | 
            +
             | 
| 107 98 | 
             
                def span_for(name = :default)
         | 
| 108 99 | 
             
                  acts_as_span_klasses[name] ||= SpanKlass.new(name, self, acts_as_span_definitions[name])
         | 
| 109 100 | 
             
                end
         | 
| 110 | 
            -
             | 
| 101 | 
            +
             | 
| 111 102 | 
             
                def acts_as_span_klasses
         | 
| 112 103 | 
             
                  @_acts_as_span_klasses ||= {}
         | 
| 113 104 | 
             
                end
         | 
| 114 105 | 
             
              end
         | 
| 115 | 
            -
             | 
| 106 | 
            +
             | 
| 116 107 | 
             
              module IncludedInstanceMethods
         | 
| 117 108 | 
             
                def spans
         | 
| 118 109 | 
             
                  acts_as_span_definitions.keys.map { |acts_as_span_definition_name| span_for(acts_as_span_definition_name) }
         | 
| 119 110 | 
             
                end
         | 
| 120 | 
            -
             | 
| 111 | 
            +
             | 
| 121 112 | 
             
                def span
         | 
| 122 113 | 
             
                  span_for(:default)
         | 
| 123 114 | 
             
                end
         | 
| 124 | 
            -
             | 
| 115 | 
            +
             | 
| 125 116 | 
             
                def span_for(name = :default)
         | 
| 126 117 | 
             
                  acts_as_span_instances[name] ||= SpanInstance.new(name, self, acts_as_span_definitions[name])
         | 
| 127 118 | 
             
                end
         | 
| 128 | 
            -
             | 
| 119 | 
            +
             | 
| 129 120 | 
             
                def acts_as_span_instances
         | 
| 130 121 | 
             
                  @_acts_as_span_instances ||= {}
         | 
| 131 122 | 
             
                end
         | 
| 132 | 
            -
             | 
| 123 | 
            +
             | 
| 133 124 | 
             
                def validate_spans
         | 
| 134 125 | 
             
                  spans.each(&:validate)
         | 
| 135 126 | 
             
                end
         | 
| 136 | 
            -
                
         | 
| 137 | 
            -
                #This syntax assumes :default span
         | 
| 138 | 
            -
                def overlap?(other_record)
         | 
| 139 | 
            -
                  overlap_for?(other_record, :default, :default)
         | 
| 140 | 
            -
                end
         | 
| 141 | 
            -
                
         | 
| 142 | 
            -
                #record.span_for(:this_span_name).overlap?(other_record.span_for(:other_record_span_name))
         | 
| 143 | 
            -
                def overlap_for?(other_record, this_span_name = :default, other_record_span_name = :default)
         | 
| 144 | 
            -
                  span_for(this_span_name).overlap?(other_record.span_for(other_record_span_name))
         | 
| 145 | 
            -
                end
         | 
| 146 | 
            -
              end
         | 
| 147 | 
            -
              
         | 
| 148 | 
            -
              module PositiveMethods
         | 
| 149 | 
            -
                send(:define_method, "acts_as_span?") do
         | 
| 150 | 
            -
                  true
         | 
| 151 | 
            -
                end
         | 
| 152 | 
            -
              end
         | 
| 153 | 
            -
              
         | 
| 154 | 
            -
              module NegativeMethods
         | 
| 155 | 
            -
                send(:define_method, "acts_as_span?") do
         | 
| 156 | 
            -
                  false
         | 
| 157 | 
            -
                end
         | 
| 158 127 | 
             
              end
         | 
| 159 128 | 
             
            end
         | 
| 160 129 |  | 
| 161 | 
            -
             | 
| 130 | 
            +
            if Object.const_defined?("ActiveRecord")
         | 
| 131 | 
            +
              ActiveRecord::Base.send(:include, ActsAsSpan)
         | 
| 132 | 
            +
            end
         | 
| @@ -0,0 +1,198 @@ | |
| 1 | 
            +
            # frozen_string_Literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module ActsAsSpan
         | 
| 4 | 
            +
              # # End Date Propagator
         | 
| 5 | 
            +
              #
         | 
| 6 | 
            +
              # When editing the `end_date` of a record, the record's children often also
         | 
| 7 | 
            +
              #   need to be updated. This propagator takes care of that.
         | 
| 8 | 
            +
              # For each of the  child records (defined below in the function `children`),
         | 
| 9 | 
            +
              #   the child record's `end_date` is updated to match that of the original
         | 
| 10 | 
            +
              #   object. The function `propagate` is recursive, propagating to
         | 
| 11 | 
            +
              #   children of children and so on.
         | 
| 12 | 
            +
              # Records that should not have their end dates propagated in this manner
         | 
| 13 | 
            +
              #   (e.g. StatusRecords) are manually excluded in `skipped_classes`.
         | 
| 14 | 
            +
              # If there is some error preventing propagation, the child record is NOT saved
         | 
| 15 | 
            +
              #   and that error message is added to the object's `errors`. These errors
         | 
| 16 | 
            +
              #   propagate upwards into a flattened array of error messages.
         | 
| 17 | 
            +
              #
         | 
| 18 | 
            +
              # This class uses its own definition of 'child' for an object. For a given
         | 
| 19 | 
            +
              #   object, the objects the propagator considers its children are:
         | 
| 20 | 
            +
              #   * Associated via `has_many` association
         | 
| 21 | 
            +
              #   * Association `:dependent` option is `:delete` or `:destroy`
         | 
| 22 | 
            +
              #   * acts_as_span (checked via `respond_to?(:span)`)
         | 
| 23 | 
            +
              #   * Not blacklisted via `skipped_classes` array
         | 
| 24 | 
            +
              #
         | 
| 25 | 
            +
              # The return value for `call` is the given object, updated to have children's
         | 
| 26 | 
            +
              #   errors added to its `:base` errors if any children had errors.
         | 
| 27 | 
            +
              #
         | 
| 28 | 
            +
              # ## Usage:
         | 
| 29 | 
            +
              #
         | 
| 30 | 
            +
              # Propagate end dates for an object that acts_as_span and has propagatable
         | 
| 31 | 
            +
              # children to all propagatable children:
         | 
| 32 | 
            +
              # ```
         | 
| 33 | 
            +
              # ActsAsSpan::EndDatePropagator.call(object)
         | 
| 34 | 
            +
              # ```
         | 
| 35 | 
            +
              #
         | 
| 36 | 
            +
              # To propagate to a subset of its propagatable children:
         | 
| 37 | 
            +
              # ```
         | 
| 38 | 
            +
              # ActsAsSpan::EndDatePropagator.call(
         | 
| 39 | 
            +
              #   object, skipped_classes: [ClassOne, ClassTwo]
         | 
| 40 | 
            +
              # )
         | 
| 41 | 
            +
              # ```
         | 
| 42 | 
            +
              # ... where ClassOne and ClassTwo are the classes to be excluded.
         | 
| 43 | 
            +
              #
         | 
| 44 | 
            +
              # The EndDatePropagator does not use transactions. If the propagation should
         | 
| 45 | 
            +
              # be run in a transaction, wrap the call in one like so:
         | 
| 46 | 
            +
              # ```
         | 
| 47 | 
            +
              # ActiveRecord::Base.transaction do
         | 
| 48 | 
            +
              #   ActsAsSpan::EndDatePropagator.call(
         | 
| 49 | 
            +
              #     obj, skipped_classes: [ClassOne, ClassTwo]
         | 
| 50 | 
            +
              #   )
         | 
| 51 | 
            +
              # end
         | 
| 52 | 
            +
              # ```
         | 
| 53 | 
            +
              #
         | 
| 54 | 
            +
              # One use case for the transaction wrapper would be to not follow through
         | 
| 55 | 
            +
              # with propagation if the object has errors:
         | 
| 56 | 
            +
              # ```
         | 
| 57 | 
            +
              # ActiveRecord::Base.transaction do
         | 
| 58 | 
            +
              #   result = ActsAsSpan::EndDatePropagator.call(obj)
         | 
| 59 | 
            +
              #   if result.errors.present?
         | 
| 60 | 
            +
              #     fail OhNoMyObjetHasErrorsError, "Oh, no! My object has errors!"
         | 
| 61 | 
            +
              #   end
         | 
| 62 | 
            +
              # end
         | 
| 63 | 
            +
              # ```
         | 
| 64 | 
            +
              #
         | 
| 65 | 
            +
              # Currently only propagates "default" span. The approach to implementing such
         | 
| 66 | 
            +
              #   a feature is ambiguous - would all children have the same span propagated?
         | 
| 67 | 
            +
              #   Would each acts_as_span model need a method to tell which span to
         | 
| 68 | 
            +
              #   propagate to? Once there is a solid use case for using this object on
         | 
| 69 | 
            +
              #   models with multiple spans, that will inform the implementation strategy.
         | 
| 70 | 
            +
              class EndDatePropagator
         | 
| 71 | 
            +
                attr_reader :object,
         | 
| 72 | 
            +
                            :errors_cache,
         | 
| 73 | 
            +
                            :skipped_classes,
         | 
| 74 | 
            +
                            :include_errors
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                def initialize(object, errors_cache: [], skipped_classes: [], include_errors: true)
         | 
| 77 | 
            +
                  @object = object
         | 
| 78 | 
            +
                  @errors_cache = errors_cache
         | 
| 79 | 
            +
                  @skipped_classes = skipped_classes
         | 
| 80 | 
            +
                  @include_errors = include_errors
         | 
| 81 | 
            +
                end
         | 
| 82 | 
            +
             | 
| 83 | 
            +
                # class-level call: enable the usage of ActsAsSpan::EndDatePropagator.call
         | 
| 84 | 
            +
                def self.call(object, **opts)
         | 
| 85 | 
            +
                  new(object, opts).call
         | 
| 86 | 
            +
                end
         | 
| 87 | 
            +
             | 
| 88 | 
            +
                def call
         | 
| 89 | 
            +
                  result = propagate
         | 
| 90 | 
            +
                  # only add new errors to the object
         | 
| 91 | 
            +
                  result.errors.each do |error, message|
         | 
| 92 | 
            +
                    unless object.errors[error].include? message
         | 
| 93 | 
            +
                      object.errors.add(error, message: message)
         | 
| 94 | 
            +
                    end
         | 
| 95 | 
            +
                  end
         | 
| 96 | 
            +
                  object
         | 
| 97 | 
            +
                end
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                private
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                def propagate
         | 
| 102 | 
            +
                  # return if there is nothing to propagate
         | 
| 103 | 
            +
                  return object unless should_propagate_from? object
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                  children(object).each do |child|
         | 
| 106 | 
            +
                    # End the record, its children too. And their children, forever, true.
         | 
| 107 | 
            +
                    propagated_child = assign_end_date(child, object.span.end_date)
         | 
| 108 | 
            +
             | 
| 109 | 
            +
                    # save child and add errors to cache
         | 
| 110 | 
            +
                    save_with_errors(object, child, propagated_child)
         | 
| 111 | 
            +
                  end
         | 
| 112 | 
            +
             | 
| 113 | 
            +
                  if errors_cache.present?
         | 
| 114 | 
            +
                    errors_cache.each do |message|
         | 
| 115 | 
            +
                      skip if object.errors.added?(:base, message)
         | 
| 116 | 
            +
             | 
| 117 | 
            +
                      object.errors.add(:base, message: message)
         | 
| 118 | 
            +
                    end
         | 
| 119 | 
            +
                  end
         | 
| 120 | 
            +
             | 
| 121 | 
            +
                  # return the object, with any newly-added errors
         | 
| 122 | 
            +
                  object
         | 
| 123 | 
            +
                end
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                # returns the given child, but possibly with errors
         | 
| 126 | 
            +
                def assign_end_date(child, new_end_date)
         | 
| 127 | 
            +
                  child.assign_attributes({ child.span.end_field => new_end_date })
         | 
| 128 | 
            +
                  ActsAsSpan::EndDatePropagator.call(
         | 
| 129 | 
            +
                    child,
         | 
| 130 | 
            +
                    errors_cache: errors_cache,
         | 
| 131 | 
            +
                    skipped_classes: skipped_classes,
         | 
| 132 | 
            +
                  )
         | 
| 133 | 
            +
                end
         | 
| 134 | 
            +
             | 
| 135 | 
            +
                # save the child record, add errors.
         | 
| 136 | 
            +
                def save_with_errors(object, child, propagated_child)
         | 
| 137 | 
            +
                  if object_has_errors?(propagated_child) && include_errors
         | 
| 138 | 
            +
                    errors_cache << propagation_error_message(object, child)
         | 
| 139 | 
            +
                  end
         | 
| 140 | 
            +
                  child.save
         | 
| 141 | 
            +
                end
         | 
| 142 | 
            +
             | 
| 143 | 
            +
                def propagation_error_message(object, child)
         | 
| 144 | 
            +
                  I18n.t(
         | 
| 145 | 
            +
                    'propagation_failure',
         | 
| 146 | 
            +
                    scope: %i[activerecord errors messages end_date_propagator],
         | 
| 147 | 
            +
                    end_date_field_name: child.class.human_attribute_name(
         | 
| 148 | 
            +
                      child.span.end_field,
         | 
| 149 | 
            +
                    ),
         | 
| 150 | 
            +
                    parent: object.model_name.human,
         | 
| 151 | 
            +
                    child: child.model_name.human,
         | 
| 152 | 
            +
                    reason: child.errors.full_messages.join('; '),
         | 
| 153 | 
            +
                  )
         | 
| 154 | 
            +
                end
         | 
| 155 | 
            +
             | 
| 156 | 
            +
                def object_has_errors?(object)
         | 
| 157 | 
            +
                  !object.valid? ||
         | 
| 158 | 
            +
                    (object.errors.present? && object.errors.messages.values.flatten.any?)
         | 
| 159 | 
            +
                end
         | 
| 160 | 
            +
             | 
| 161 | 
            +
                # check if the end_date analog is dirtied
         | 
| 162 | 
            +
                def end_date_changed?(object)
         | 
| 163 | 
            +
                  end_date_field = object.span.end_field.to_s
         | 
| 164 | 
            +
                  object.changed.include? end_date_field
         | 
| 165 | 
            +
                end
         | 
| 166 | 
            +
             | 
| 167 | 
            +
                def should_propagate_from?(object)
         | 
| 168 | 
            +
                  object.respond_to?(:span) &&
         | 
| 169 | 
            +
                    end_date_changed?(object) &&
         | 
| 170 | 
            +
                    !object.span.end_date.nil?
         | 
| 171 | 
            +
                end
         | 
| 172 | 
            +
             | 
| 173 | 
            +
                # Use acts_as_span to determine whether a record has an end date
         | 
| 174 | 
            +
                def should_propagate_to?(klass)
         | 
| 175 | 
            +
                  klass.respond_to?(:span) && @skipped_classes.exclude?(klass)
         | 
| 176 | 
            +
                end
         | 
| 177 | 
            +
             | 
| 178 | 
            +
                def child_associations(object)
         | 
| 179 | 
            +
                  object.class.reflect_on_all_associations(:has_many).select do |reflection|
         | 
| 180 | 
            +
                    %i[delete destroy].include?(reflection.options[:dependent]) &&
         | 
| 181 | 
            +
                      should_propagate_to?(reflection.klass)
         | 
| 182 | 
            +
                  end
         | 
| 183 | 
            +
                end
         | 
| 184 | 
            +
             | 
| 185 | 
            +
                def children(object)
         | 
| 186 | 
            +
                  child_objects = child_associations(object).flat_map do |reflection|
         | 
| 187 | 
            +
                    object.send(reflection.name)
         | 
| 188 | 
            +
                  end
         | 
| 189 | 
            +
             | 
| 190 | 
            +
                  # skip previously-ended children
         | 
| 191 | 
            +
                  child_objects.reject do |child|
         | 
| 192 | 
            +
                    child.span.end_date && child.span.end_date < object.span.end_date
         | 
| 193 | 
            +
                  end
         | 
| 194 | 
            +
                end
         | 
| 195 | 
            +
             | 
| 196 | 
            +
                attr_writer :object, :errors_cache
         | 
| 197 | 
            +
              end
         | 
| 198 | 
            +
            end
         |