class ActsAsSpan::EndDatePropagator

# End Date Propagator

When editing the `end_date` of a record, the record's children often also

need to be updated. This propagator takes care of that.

For each of the child records (defined below in the function `children`),

the child record's `end_date` is updated to match that of the original
object. The function `propagate` is recursive, propagating to
children of children and so on.

Records that should not have their end dates propagated in this manner

(e.g. StatusRecords) are manually excluded in `skipped_classes`.

If there is some error preventing propagation, the child record is NOT saved

and that error message is added to the object's `errors`. These errors
propagate upwards into a flattened array of error messages.

This class uses its own definition of 'child' for an object. For a given

object, the objects the propagator considers its children are:
* Associated via `has_many` association
* Association `:dependent` option is `:delete` or `:destroy`
* acts_as_span (checked via `respond_to?(:span)`)
* Not blacklisted via `skipped_classes` array

The return value for `call` is the given object, updated to have children's

errors added to its `:base` errors if any children had errors.

## Usage:

Propagate end dates for an object that acts_as_span and has propagatable children to all propagatable children: “` ActsAsSpan::EndDatePropagator.call(object) “`

To propagate to a subset of its propagatable children: “` ActsAsSpan::EndDatePropagator.call(

object, skipped_classes: [ClassOne, ClassTwo]

) “` … where ClassOne and ClassTwo are the classes to be excluded.

The EndDatePropagator does not use transactions. If the propagation should be run in a transaction, wrap the call in one like so: “` ActiveRecord::Base.transaction do

ActsAsSpan::EndDatePropagator.call(
  obj, skipped_classes: [ClassOne, ClassTwo]
)

end “`

One use case for the transaction wrapper would be to not follow through with propagation if the object has errors: “` ActiveRecord::Base.transaction do

result = ActsAsSpan::EndDatePropagator.call(obj)
if result.errors.present?
  fail OhNoMyObjetHasErrorsError, "Oh, no! My object has errors!"
end

end “`

Currently only propagates “default” span. The approach to implementing such

a feature is ambiguous - would all children have the same span propagated?
Would each acts_as_span model need a method to tell which span to
propagate to? Once there is a solid use case for using this object on
models with multiple spans, that will inform the implementation strategy.

Attributes

errors_cache[RW]
include_errors[R]
object[RW]
skipped_classes[R]

Public Class Methods

call(object, **opts) click to toggle source

class-level call: enable the usage of ActsAsSpan::EndDatePropagator.call

# File lib/acts_as_span/end_date_propagator.rb, line 84
def self.call(object, **opts)
  new(object, opts).call
end
new(object, errors_cache: [], skipped_classes: [], include_errors: true) click to toggle source
# File lib/acts_as_span/end_date_propagator.rb, line 76
def initialize(object, errors_cache: [], skipped_classes: [], include_errors: true)
  @object = object
  @errors_cache = errors_cache
  @skipped_classes = skipped_classes
  @include_errors = include_errors
end

Public Instance Methods

call() click to toggle source
# File lib/acts_as_span/end_date_propagator.rb, line 88
def call
  result = propagate
  # only add new errors to the object

  # NOTE: Rails 5 support
  if ActiveRecord::VERSION::MAJOR > 5
    add_errors(result.errors)
  else
    add_rails_5_errors(result.errors)
  end

  object
end

Private Instance Methods

add_errors(errors) click to toggle source
# File lib/acts_as_span/end_date_propagator.rb, line 104
def add_errors(errors)
  errors.each do |error|
    if object.errors[error.attribute].exclude? error.message
      object.errors.add(error.attribute, error.message)
    end
  end
end
add_rails_5_errors(errors) click to toggle source

Treat errors like a Hash NOTE: Rails 5 support

# File lib/acts_as_span/end_date_propagator.rb, line 114
def add_rails_5_errors(errors)
  errors.each do |attribute, message|
    if object.errors[attribute].exclude? message
      object.errors.add(attribute, message)
    end
  end
end
assign_end_date(child, new_end_date) click to toggle source

returns the given child, but possibly with errors

# File lib/acts_as_span/end_date_propagator.rb, line 147
def assign_end_date(child, new_end_date)
  child.assign_attributes({ child.span.end_field => new_end_date })
  ActsAsSpan::EndDatePropagator.call(
    child,
    errors_cache: errors_cache,
    skipped_classes: skipped_classes,
  )
end
child_associations(object) click to toggle source
# File lib/acts_as_span/end_date_propagator.rb, line 199
def child_associations(object)
  object.class.reflect_on_all_associations(:has_many).select do |reflection|
    %i[delete destroy].include?(reflection.options[:dependent]) &&
      should_propagate_to?(reflection.klass)
  end
end
children(object) click to toggle source
# File lib/acts_as_span/end_date_propagator.rb, line 206
def children(object)
  child_objects = child_associations(object).flat_map do |reflection|
    object.send(reflection.name)
  end

  # skip previously-ended children
  child_objects.reject do |child|
    child.span.end_date && child.span.end_date < object.span.end_date
  end
end
end_date_changed?(object) click to toggle source

check if the end_date analog is dirtied

# File lib/acts_as_span/end_date_propagator.rb, line 183
def end_date_changed?(object)
  end_date_field = object.span.end_field.to_s
  object.changed.include? end_date_field
end
object_has_errors?(object) click to toggle source
# File lib/acts_as_span/end_date_propagator.rb, line 177
def object_has_errors?(object)
  !object.valid? ||
    (object.errors.present? && object.errors.messages.values.flatten.any?)
end
propagate() click to toggle source
# File lib/acts_as_span/end_date_propagator.rb, line 122
def propagate
  # return if there is nothing to propagate
  return object unless should_propagate_from? object

  children(object).each do |child|
    # End the record, its children too. And their children, forever, true.
    propagated_child = assign_end_date(child, object.span.end_date)

    # save child and add errors to cache
    save_with_errors(object, child, propagated_child)
  end

  if errors_cache.present?
    errors_cache.each do |message|
      next if object.errors.added?(:base, message)

      object.errors.add(:base, message)
    end
  end

  # return the object, with any newly-added errors
  object
end
propagation_error_message(object, child) click to toggle source
# File lib/acts_as_span/end_date_propagator.rb, line 164
def propagation_error_message(object, child)
  I18n.t(
    'propagation_failure',
    scope: %i[activerecord errors messages end_date_propagator],
    end_date_field_name: child.class.human_attribute_name(
      child.span.end_field,
    ),
    parent: object.model_name.human,
    child: child.model_name.human,
    reason: child.errors.full_messages.join('; '),
  )
end
save_with_errors(object, child, propagated_child) click to toggle source

save the child record, add errors.

# File lib/acts_as_span/end_date_propagator.rb, line 157
def save_with_errors(object, child, propagated_child)
  if object_has_errors?(propagated_child) && include_errors
    errors_cache << propagation_error_message(object, child)
  end
  child.save
end
should_propagate_from?(object) click to toggle source
# File lib/acts_as_span/end_date_propagator.rb, line 188
def should_propagate_from?(object)
  object.respond_to?(:span) &&
    end_date_changed?(object) &&
    !object.span.end_date.nil?
end
should_propagate_to?(klass) click to toggle source

Use acts_as_span to determine whether a record has an end date

# File lib/acts_as_span/end_date_propagator.rb, line 195
def should_propagate_to?(klass)
  klass.respond_to?(:span) && @skipped_classes.exclude?(klass)
end