acts_as_span 0.0.6 → 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 9ed7f152ad93220467d76ca831763a2a4dbd02ba
4
- data.tar.gz: eaf693675a0aa764b8d85a77464b9897787993be
2
+ SHA256:
3
+ metadata.gz: b325eefc9a4503a854a5af5f9d62b23820ce5177752ee95b48128d3d63d86e7b
4
+ data.tar.gz: 89c73478dc48ca7f8f70d7e74d52a96c4660dfa4458a100b93b0a6216ce5ef5f
5
5
  SHA512:
6
- metadata.gz: 686a48328384d0e29b0aba18af0d957cc06d0e8ab4fd051d72a33a8515c23115eca337427424f0e920edbf5970abb3605d12b582fa9df37799a0ed911624d8de
7
- data.tar.gz: a35877b8ff3c5748a921fb095dd925791e1212246dc0442835a5e3e5c77cbdc30ec659a69d56cc55fbc0e4eee154f53babf7a70a26ee194674a9ad59e4cb0cdb
6
+ metadata.gz: 5130c797f801bbd7e29bf89311603bf8d29c0659107e9c50e231d9c123f25dadc55793d54ba885ad08b1fe4e77486eab41516e8194133f9ceca693e2a94b398c
7
+ data.tar.gz: 7f42fe723d78b4b129b63a07677c9551ac50005d84d7ff6c5f45ccd21088a7e453122ae7fe5734c9e34636cb9ad05dbb877517e8198ef2ea64a24bc3a5ba1513
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 2.6.5
data/.travis.yml CHANGED
@@ -1,6 +1,12 @@
1
1
  language: ruby
2
2
  rvm:
3
- - 2.2.8
4
- - 2.3.5
3
+ - 2.4.5
4
+ - 2.5.3
5
+ - 2.6.5
6
+
7
+ # match the bundler version in the gemspec
8
+ before_install:
9
+ - gem install -v 2.1.4 bundler --no-document
10
+ - bundle _2.1.4_ install
5
11
  notifications:
6
12
  email: false
data/acts_as_span.gemspec CHANGED
@@ -1,39 +1,40 @@
1
- # -*- encoding: utf-8 -*-
2
- $:.push File.expand_path("../lib", __FILE__)
3
- require "acts_as_span/version"
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 = "acts_as_span"
7
+ s.name = 'acts_as_span'
7
8
  s.version = ActsAsSpan::VERSION::STRING
8
- s.authors = ["Eric Sullivan"]
9
- s.email = ["eric.sullivan@annkissam.com"]
10
- s.homepage = "https://github.com/annkissam/acts_as_span"
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 = %q{ActiveRecord model w/ a start_date and an end_date == ActsAsSpan}
13
- s.license = "MIT"
13
+ s.description = 'ActiveRecord model w/ a start_date and an end_date == ActsAsSpan'
14
+ s.license = 'MIT'
14
15
 
15
16
  # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
16
17
  # to allow pushing to a single host or delete this section to allow pushing to any host.
17
18
  if s.respond_to?(:metadata)
18
- s.metadata["allowed_push_host"] = "https://rubygems.org"
19
+ s.metadata['allowed_push_host'] = 'https://rubygems.org'
19
20
  else
20
- raise "RubyGems 2.0 or newer is required to protect against " \
21
- "public gem pushes."
21
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
22
+ 'public gem pushes.'
22
23
  end
23
24
 
24
25
  s.files = `git ls-files`.split("\n")
25
26
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
26
- s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
27
- s.require_paths = ["lib"]
27
+ s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
28
+ s.require_paths = %w[lib]
28
29
 
29
- s.add_development_dependency "bundler", "~> 1.15"
30
- s.add_development_dependency "rake", "~> 10.0"
31
- s.add_development_dependency "rspec", "~> 3.0"
32
- s.add_development_dependency "sqlite3"
33
- s.add_development_dependency "has_siblings", "~> 0.2.7"
34
- s.add_development_dependency "temping"
35
- s.add_development_dependency "pry-byebug"
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'
36
37
 
37
- s.add_runtime_dependency('activerecord', '>= 4.2.0')
38
- s.add_runtime_dependency('activesupport', '>= 4.2.0')
38
+ s.add_runtime_dependency('activerecord', '>= 5.0.0')
39
+ s.add_runtime_dependency('activesupport', '>= 5.0.0')
39
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
@@ -6,13 +6,18 @@ require 'acts_as_span/span_instance'
6
6
  require 'acts_as_span/no_overlap_validator'
7
7
  require 'acts_as_span/within_parent_date_span_validator'
8
8
 
9
+ require 'acts_as_span/end_date_propagator'
9
10
 
10
11
  require 'active_support'
11
12
  require 'active_record'
12
13
 
14
+ I18n.load_path += Dir[File.join(File.dirname(__dir__), 'config', 'locales', '**', 'acts_as_span.yml')]
15
+
13
16
  module ActsAsSpan
14
17
  extend ActiveSupport::Concern
15
18
 
19
+ OPTIONS = %i[start_field end_field name].freeze
20
+
16
21
  class << self
17
22
  def options
18
23
  @options ||= {
@@ -32,8 +37,17 @@ module ActsAsSpan
32
37
  self.send(:extend, ActsAsSpan::ExtendedClassMethods)
33
38
  self.send(:include, ActsAsSpan::IncludedInstanceMethods)
34
39
 
40
+ # TODO: There's some refactoring that could be done here using keyword args (or the more standard old hash arg pattern)
35
41
  options = OpenStruct.new(args.last.is_a?(Hash) ? ActsAsSpan.options.merge(args.pop) : ActsAsSpan.options)
36
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
+
37
51
  acts_as_span_definitions[options.name] = options
38
52
 
39
53
  # TODO add tests that check delegation of all methos in span
@@ -0,0 +1,196 @@
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
+ object.errors.add(error) if object.errors[error].exclude? message
93
+ end
94
+ object
95
+ end
96
+
97
+ private
98
+
99
+ def propagate
100
+ # return if there is nothing to propagate
101
+ return object unless should_propagate_from? object
102
+
103
+ children(object).each do |child|
104
+ # End the record, its children too. And their children, forever, true.
105
+ propagated_child = assign_end_date(child, object.span.end_date)
106
+
107
+ # save child and add errors to cache
108
+ save_with_errors(object, child, propagated_child)
109
+ end
110
+
111
+ if errors_cache.present?
112
+ errors_cache.each do |message|
113
+ skip if object.errors.added?(:base, message)
114
+
115
+ object.errors.add(:base, message)
116
+ end
117
+ end
118
+
119
+ # return the object, with any newly-added errors
120
+ object
121
+ end
122
+
123
+ # returns the given child, but possibly with errors
124
+ def assign_end_date(child, new_end_date)
125
+ child.assign_attributes({ child.span.end_field => new_end_date })
126
+ ActsAsSpan::EndDatePropagator.call(
127
+ child,
128
+ errors_cache: errors_cache,
129
+ skipped_classes: skipped_classes,
130
+ )
131
+ end
132
+
133
+ # save the child record, add errors.
134
+ def save_with_errors(object, child, propagated_child)
135
+ if object_has_errors?(propagated_child) && include_errors
136
+ errors_cache << propagation_error_message(object, child)
137
+ end
138
+ child.save
139
+ end
140
+
141
+ def propagation_error_message(object, child)
142
+ I18n.t(
143
+ 'propagation_failure',
144
+ scope: %i[activerecord errors messages end_date_propagator],
145
+ end_date_field_name: child.class.human_attribute_name(
146
+ child.span.end_field,
147
+ ),
148
+ parent: object.model_name.human,
149
+ child: child.model_name.human,
150
+ reason: child.errors.full_messages.join('; '),
151
+ )
152
+ end
153
+
154
+ def object_has_errors?(object)
155
+ !object.valid? ||
156
+ (object.errors.present? && object.errors.messages.values.flatten.any?)
157
+ end
158
+
159
+ # check if the end_date analog is dirtied
160
+ def end_date_changed?(object)
161
+ end_date_field = object.span.end_field.to_s
162
+ object.changed.include? end_date_field
163
+ end
164
+
165
+ def should_propagate_from?(object)
166
+ object.respond_to?(:span) &&
167
+ end_date_changed?(object) &&
168
+ !object.span.end_date.nil?
169
+ end
170
+
171
+ # Use acts_as_span to determine whether a record has an end date
172
+ def should_propagate_to?(klass)
173
+ klass.respond_to?(:span) && @skipped_classes.exclude?(klass)
174
+ end
175
+
176
+ def child_associations(object)
177
+ object.class.reflect_on_all_associations(:has_many).select do |reflection|
178
+ %i[delete destroy].include?(reflection.options[:dependent]) &&
179
+ should_propagate_to?(reflection.klass)
180
+ end
181
+ end
182
+
183
+ def children(object)
184
+ child_objects = child_associations(object).flat_map do |reflection|
185
+ object.send(reflection.name)
186
+ end
187
+
188
+ # skip previously-ended children
189
+ child_objects.reject do |child|
190
+ child.span.end_date && child.span.end_date < object.span.end_date
191
+ end
192
+ end
193
+
194
+ attr_writer :object, :errors_cache
195
+ end
196
+ end
@@ -1,49 +1,84 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'active_model'
2
4
 
3
5
  module ActsAsSpan
6
+ # Validator that checks whether a record is overlapping with others
7
+ #
8
+ # Takes options `:instance_scope` (optional) and `:scope` (required):
9
+ # * `instance_scope` is a proc which, when evaluated by the record, returns
10
+ # a boolean value. When false, the validatior will not check for overlap.
11
+ # When true, the validator checks normally.
12
+ # * `scope` is also a proc. This is must return an ActiveRecord Relation that
13
+ # determines which records' spans to compare.
14
+ #
15
+ # Usage:
16
+ # Given a record with `siblings` defined, the most basic use case is:
17
+ # ```
18
+ # validates_with ActsAsSpan::NoOverlapValidator,
19
+ # scope: proc { siblings }
20
+ # ```
21
+ # When this record is validated, every record in the ActiveRecord relation
22
+ # `record.siblings` is checked for mutual overlap with `record`.
23
+ #
24
+ # Use `instance_scope` if there is some condition where a record oughtn't be
25
+ # validated for whatever reason:
26
+ # ```
27
+ # validates_with ActsAsSpan::NoOverlapValidator,
28
+ # scope: proc { siblings }, instance_scope: proc { favorite? }
29
+ # ```
30
+ # Now, when this record is validated, if `record.favorite?` is `true`,
31
+ # `record` must pass the overlap check with its siblings.
32
+ # If `record.favorite?` is `false`, it is under less scrutiny.
33
+ #
4
34
  class NoOverlapValidator < ActiveModel::Validator
5
35
  def validate(record)
6
36
  overlapping_records = temporally_overlapping_for(record)
7
- instance_scope = options[:instance_scope].is_a?(Proc) ? record.instance_eval(&options[:instance_scope]) : true
8
-
9
- if overlapping_records.any? && instance_scope
37
+ instance_scope = if options[:instance_scope].is_a? Proc
38
+ record.instance_eval(&options[:instance_scope])
39
+ else
40
+ true
41
+ end
10
42
 
11
- error_type = overlapping_records.size == 1 ? "no_overlap.one" : "no_overlap.other"
43
+ return unless overlapping_records.any? && instance_scope
12
44
 
13
- record.errors.add(
14
- :base,
15
- error_type.to_sym,
16
- model_name: record.class.model_name.human,
17
- model_name_plural: record.class.model_name.plural.humanize,
18
- start_date: record.start_date,
19
- end_date: record.end_date,
20
- count: overlapping_records.size,
21
- overlapping_records_s: overlapping_records.join(",")
22
- )
23
- end
45
+ error_message = options[:message] || :no_overlap
46
+ record.errors.add(
47
+ :base,
48
+ error_message,
49
+ model_name: record.class.model_name.human,
50
+ model_name_plural: record.class.model_name.plural.humanize,
51
+ start_date: record.span.start_date,
52
+ end_date: record.span.end_date,
53
+ count: overlapping_records.size,
54
+ overlapping_records_s: overlapping_records.join(', ')
55
+ )
24
56
  end
25
57
 
26
- #TODO add back condition for start_date nil
27
- #TODO add configuration for span configuration
58
+ # TODO: add back condition for start_date nil
59
+ # TODO: add support for multiple spans (currently only checks :default)
28
60
  def temporally_overlapping_for(record)
29
61
  scope = record.instance_eval(&options[:scope])
30
62
 
31
- start_date = record.start_date || Date.current
32
- end_date = record.end_date
63
+ start_date = record.span.start_date || Date.current
64
+
65
+ end_date = record.span.end_date
66
+ end_field = record.span.end_field
67
+
33
68
  arel_table = record.class.arel_table
34
69
 
35
70
  if end_date
36
71
  scope.where(
37
- arel_table[:start_date].lteq(end_date).
38
- and(
39
- arel_table[:end_date].gteq(start_date).
40
- or(arel_table[:end_date].eq(nil))
72
+ arel_table[record.span.start_field].lteq(end_date)
73
+ .and(
74
+ arel_table[end_field].gteq(start_date)
75
+ .or(arel_table[end_field].eq(nil))
41
76
  )
42
77
  )
43
78
  else
44
79
  scope.where(
45
- arel_table[:end_date].gteq(start_date).
46
- or(arel_table[:end_date].eq(nil))
80
+ arel_table[end_field].gteq(start_date)
81
+ .or(arel_table[end_field].eq(nil))
47
82
  )
48
83
  end
49
84
  end