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 +4 -4
- data/.tool-versions +1 -0
- data/.travis.yml +6 -2
- data/CHANGELOG.md +22 -0
- data/PULL_REQUEST_TEMPLATE.md +5 -0
- data/acts_as_span.gemspec +24 -23
- data/config/locales/en/acts_as_span.yml +15 -0
- data/lib/acts_as_span.rb +3 -0
- data/lib/acts_as_span/end_date_propagator.rb +219 -0
- data/lib/acts_as_span/no_overlap_validator.rb +60 -25
- data/lib/acts_as_span/span_instance.rb +8 -0
- data/lib/acts_as_span/span_instance/validations.rb +7 -1
- data/lib/acts_as_span/span_klass/status.rb +14 -4
- data/lib/acts_as_span/version.rb +4 -2
- data/lib/acts_as_span/within_parent_date_span_validator.rb +9 -7
- data/spec/lib/end_date_propagator_spec.rb +344 -0
- data/spec/lib/no_overlap_validator_spec.rb +34 -1
- data/spec/lib/span_instance_spec.rb +12 -0
- data/spec/lib/span_klass/status_spec.rb +38 -0
- data/spec/lib/within_parent_date_span_validator_spec.rb +11 -0
- data/spec/spec_models.rb +157 -1
- metadata +57 -37
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dce2b4057e3f17e47bc079858edd883ae157fab1ac07255e352471fc0d4a734e
|
4
|
+
data.tar.gz: 004a557469880ade02c121b63446f3014ce4bcea43ab6febfdc88e7e852fec30
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
6
|
-
|
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
|
data/acts_as_span.gemspec
CHANGED
@@ -1,39 +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.license =
|
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[
|
19
|
+
s.metadata['allowed_push_host'] = 'https://rubygems.org'
|
19
20
|
else
|
20
|
-
raise
|
21
|
-
|
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 = [
|
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
|
30
|
-
s.add_development_dependency
|
31
|
-
s.add_development_dependency
|
32
|
-
s.add_development_dependency
|
33
|
-
s.add_development_dependency
|
34
|
-
s.add_development_dependency
|
35
|
-
s.add_development_dependency
|
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', '>=
|
38
|
-
s.add_runtime_dependency('activesupport', '>=
|
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?
|
8
|
-
|
9
|
-
|
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
|
-
|
43
|
+
return unless overlapping_records.any? && instance_scope
|
12
44
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
)
|
23
|
-
|
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
|
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
|
-
|
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[
|
38
|
-
and(
|
39
|
-
arel_table[
|
40
|
-
|
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[
|
46
|
-
or(arel_table[
|
80
|
+
arel_table[end_field].gteq(start_date)
|
81
|
+
.or(arel_table[end_field].eq(nil))
|
47
82
|
)
|
48
83
|
end
|
49
84
|
end
|