acts_as_span 1.0.0 → 1.2.2

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 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