acts_as_span 1.0.0 → 1.2.2

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
2
  SHA256:
3
- metadata.gz: d1b2bd958d53e10e1816b39bbb073c8b9d21b105ab20b7bdd1d0f11048f2adf7
4
- data.tar.gz: 9f211ee95efdcbad216591259729983fbf31fc26f6ce91d39fdd03be258d0c0e
3
+ metadata.gz: dce2b4057e3f17e47bc079858edd883ae157fab1ac07255e352471fc0d4a734e
4
+ data.tar.gz: 004a557469880ade02c121b63446f3014ce4bcea43ab6febfdc88e7e852fec30
5
5
  SHA512:
6
- metadata.gz: aa5735e3051308f569cee93a81b13b833c33c1611feaacc0cfc2b985f1938dbaac923676bcd5e0fc75ed91f428651a3b2e10ff2a4ed48981e6191acc60494bd1
7
- data.tar.gz: 2fc6f890b396e9829559aa063f6bdd6dfb21b1afc0806a83dc1114acbc736af6ce0cf6dec2c319a259ad1adfdbd52657f81c2adc439b32a3d05c4844f179b7f5
6
+ metadata.gz: eef25c86ddff4ce610a0cfe4e2a9b3fbf983768821566c0b056cdbb117d82f9ab0573c2309cb839ac61077f00fb7a5a6f1302f8bd64371ce43a5fa1d6068bfaa
7
+ data.tar.gz: 7815bd5e31b5eca225a88278dad84af946a98ad0adef280b5dcdd24f358ae3a4c2005da40dfc035344525c9f157cf98e96aea52b526c5a69c729179849c6e135
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 2.6.5
data/.travis.yml CHANGED
@@ -2,7 +2,11 @@ language: ruby
2
2
  rvm:
3
3
  - 2.4.5
4
4
  - 2.5.3
5
- - 2.6.1
6
- before_install: gem install bundler
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
7
11
  notifications:
8
12
  email: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ Types of changes:
9
+ - **Added** for new features.
10
+ - **Changed** for changes in existing functionality.
11
+ - **Deprecated** for soon-to-be removed features.
12
+ - **Removed** for now removed features.
13
+ - **Fixed** for any bug fixes.
14
+ - **Security** in case of vulnerabilities.
15
+
16
+ Please include the Github issue or pull request number when applicable
17
+
18
+ ## [Unreleased]
19
+ ### Added
20
+ - A change log #31
21
+ ### Fixed
22
+ - Syntax error in EndDatePropagator#propagate #30
@@ -0,0 +1,5 @@
1
+ ## Checklist
2
+ - [ ] Continuous integration passes, or no functional code was changed
3
+ - [ ] Changelog entry added, or no entry is necessary
4
+
5
+ ## Notes
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", "~> 2.0.1"
30
- s.add_development_dependency "rake", "~> 10.0"
31
- s.add_development_dependency "rspec", "~> 3.0"
32
- s.add_development_dependency "sqlite3", "~> 1.3.6"
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,10 +6,13 @@ 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
 
@@ -0,0 +1,219 @@
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
+
92
+ # NOTE: Rails 5 support
93
+ if ActiveRecord::VERSION::MAJOR > 5
94
+ add_errors(result.errors)
95
+ else
96
+ add_rails_5_errors(result.errors)
97
+ end
98
+
99
+ object
100
+ end
101
+
102
+ private
103
+
104
+ def add_errors(errors)
105
+ errors.each do |error|
106
+ if object.errors[error.attribute].exclude? error.message
107
+ object.errors.add(error.attribute, error.message)
108
+ end
109
+ end
110
+ end
111
+
112
+ # Treat errors like a Hash
113
+ # NOTE: Rails 5 support
114
+ def add_rails_5_errors(errors)
115
+ errors.each do |attribute, message|
116
+ if object.errors[attribute].exclude? message
117
+ object.errors.add(attribute, message)
118
+ end
119
+ end
120
+ end
121
+
122
+ def propagate
123
+ # return if there is nothing to propagate
124
+ return object unless should_propagate_from? object
125
+
126
+ children(object).each do |child|
127
+ # End the record, its children too. And their children, forever, true.
128
+ propagated_child = assign_end_date(child, object.span.end_date)
129
+
130
+ # save child and add errors to cache
131
+ save_with_errors(object, child, propagated_child)
132
+ end
133
+
134
+ if errors_cache.present?
135
+ errors_cache.each do |message|
136
+ next if object.errors.added?(:base, message)
137
+
138
+ object.errors.add(:base, message)
139
+ end
140
+ end
141
+
142
+ # return the object, with any newly-added errors
143
+ object
144
+ end
145
+
146
+ # returns the given child, but possibly with errors
147
+ def assign_end_date(child, new_end_date)
148
+ child.assign_attributes({ child.span.end_field => new_end_date })
149
+ ActsAsSpan::EndDatePropagator.call(
150
+ child,
151
+ errors_cache: errors_cache,
152
+ skipped_classes: skipped_classes,
153
+ )
154
+ end
155
+
156
+ # save the child record, add errors.
157
+ def save_with_errors(object, child, propagated_child)
158
+ if object_has_errors?(propagated_child) && include_errors
159
+ errors_cache << propagation_error_message(object, child)
160
+ end
161
+ child.save
162
+ end
163
+
164
+ def propagation_error_message(object, child)
165
+ I18n.t(
166
+ 'propagation_failure',
167
+ scope: %i[activerecord errors messages end_date_propagator],
168
+ end_date_field_name: child.class.human_attribute_name(
169
+ child.span.end_field,
170
+ ),
171
+ parent: object.model_name.human,
172
+ child: child.model_name.human,
173
+ reason: child.errors.full_messages.join('; '),
174
+ )
175
+ end
176
+
177
+ def object_has_errors?(object)
178
+ !object.valid? ||
179
+ (object.errors.present? && object.errors.messages.values.flatten.any?)
180
+ end
181
+
182
+ # check if the end_date analog is dirtied
183
+ def end_date_changed?(object)
184
+ end_date_field = object.span.end_field.to_s
185
+ object.changed.include? end_date_field
186
+ end
187
+
188
+ def should_propagate_from?(object)
189
+ object.respond_to?(:span) &&
190
+ end_date_changed?(object) &&
191
+ !object.span.end_date.nil?
192
+ end
193
+
194
+ # Use acts_as_span to determine whether a record has an end date
195
+ def should_propagate_to?(klass)
196
+ klass.respond_to?(:span) && @skipped_classes.exclude?(klass)
197
+ end
198
+
199
+ def child_associations(object)
200
+ object.class.reflect_on_all_associations(:has_many).select do |reflection|
201
+ %i[delete destroy].include?(reflection.options[:dependent]) &&
202
+ should_propagate_to?(reflection.klass)
203
+ end
204
+ end
205
+
206
+ def children(object)
207
+ child_objects = child_associations(object).flat_map do |reflection|
208
+ object.send(reflection.name)
209
+ end
210
+
211
+ # skip previously-ended children
212
+ child_objects.reject do |child|
213
+ child.span.end_date && child.span.end_date < object.span.end_date
214
+ end
215
+ end
216
+
217
+ attr_writer :object, :errors_cache
218
+ end
219
+ 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