acts_as_span 0.0.5 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -3
  3. data/.rspec +2 -0
  4. data/.tool-versions +1 -0
  5. data/.travis.yml +12 -0
  6. data/Gemfile +0 -3
  7. data/README.rdoc +4 -16
  8. data/Rakefile +5 -1
  9. data/acts_as_span.gemspec +31 -14
  10. data/config/locales/en/acts_as_span.yml +15 -0
  11. data/lib/acts_as_span.rb +69 -98
  12. data/lib/acts_as_span/end_date_propagator.rb +198 -0
  13. data/lib/acts_as_span/no_overlap_validator.rb +86 -0
  14. data/lib/acts_as_span/span_instance.rb +24 -32
  15. data/lib/acts_as_span/span_instance/status.rb +12 -27
  16. data/lib/acts_as_span/span_instance/validations.rb +11 -53
  17. data/lib/acts_as_span/span_klass.rb +11 -19
  18. data/lib/acts_as_span/span_klass/status.rb +43 -12
  19. data/lib/acts_as_span/version.rb +6 -4
  20. data/lib/acts_as_span/within_parent_date_span_validator.rb +44 -0
  21. data/spec/lib/acts_as_span_spec.rb +38 -35
  22. data/spec/lib/delegation_spec.rb +45 -78
  23. data/spec/lib/end_date_propagator_spec.rb +319 -0
  24. data/spec/lib/no_overlap_validator_spec.rb +129 -0
  25. data/spec/lib/span_instance/named_scopes_on_spec.rb +193 -193
  26. data/spec/lib/span_instance/named_scopes_spec.rb +193 -191
  27. data/spec/lib/span_instance/overlap_spec.rb +193 -253
  28. data/spec/lib/span_instance/status_spec.rb +22 -35
  29. data/spec/lib/span_instance/validations_spec.rb +8 -44
  30. data/spec/lib/span_instance_spec.rb +17 -30
  31. data/spec/lib/span_klass/status_spec.rb +38 -0
  32. data/spec/lib/within_parent_date_span_validator_spec.rb +126 -0
  33. data/spec/spec_helper.rb +19 -6
  34. data/spec/spec_models.rb +226 -0
  35. metadata +167 -61
  36. data/Gemfile.lock +0 -47
  37. data/lib/acts_as_span/span_instance/overlap.rb +0 -17
  38. data/lib/acts_as_span/span_klass/overlap.rb +0 -21
  39. data/spec/lib/negative_spec.rb +0 -30
  40. data/spec/spec.opts +0 -1
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 59d6ed6e5f0b02d6d3683a99d62ac5f3b11ccdb5cf510f5bf1a3197e095102ff
4
+ data.tar.gz: 856975edeadab3d35def0a65e51bb6d65987fc2042cdec822d72b7c76741a9cb
5
+ SHA512:
6
+ metadata.gz: cb1ae3e3c092a528e83d7dcb2fd9ca7d601b985eaa974b6eac1c1eb2634dc4630473dc5bfcafd548d3748a602569185cd803c2f7f6551c486dddcee1905e8205
7
+ data.tar.gz: 605657a4fb0114f18018b4aab29d9c2f9c63bf87321fe7c04ceaccb7974ad064dc4112ce3b7e5b44c8b541b65680e768e60ef54e8d43e1502ec59f27f46c797d
data/.gitignore CHANGED
@@ -1,3 +1,11 @@
1
- coverage
2
- rdoc
3
- pkg
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+
6
+ .DS_Store
7
+ *.log
8
+ *.sqlite
9
+
10
+ .rvmrc
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --order rand
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 2.6.5
data/.travis.yml ADDED
@@ -0,0 +1,12 @@
1
+ language: ruby
2
+ rvm:
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
11
+ notifications:
12
+ email: false
data/Gemfile CHANGED
@@ -2,6 +2,3 @@ source "http://rubygems.org"
2
2
 
3
3
  # Specify your gem's dependencies in acts_as_span.gemspec
4
4
  gemspec
5
-
6
- gem 'rspec'
7
- gem 'acts_as_fu'
data/README.rdoc CHANGED
@@ -1,6 +1,8 @@
1
1
  = acts_as_span
2
2
 
3
- ActiveRecord model w/ a start_date and an end_date
3
+ ActiveRecord model w/ a start_date and an end_date == ActsAsSpan
4
+
5
+ Treat those date spans like the objects they are!
4
6
 
5
7
  == Getting Started
6
8
 
@@ -14,20 +16,6 @@ In your model:
14
16
  acts_as_span
15
17
  end
16
18
 
17
- In your migrations:
18
-
19
- class AddSpanToSpanRecord < ActiveRecord::Migration
20
- def self.up
21
- add_column :span_records, :start_date, :date
22
- add_column :span_records, :end_date, :date
23
- end
24
-
25
- def self.down
26
- remove_column :span_records, :start_date
27
- remove_column :span_records, :end_date
28
- end
29
- end
30
-
31
19
  == Copyright
32
20
 
33
- Copyright (c) 2011 Annkissam. See LICENSE for details.
21
+ Copyright (c) 2011-2018 Annkissam. See LICENSE for details.
data/Rakefile CHANGED
@@ -1,4 +1,8 @@
1
- require 'bundler/gem_tasks'
1
+ require "bundler/gem_tasks"
2
+
3
+ #RSPEC
2
4
  require 'rspec/core/rake_task'
3
5
 
4
6
  RSpec::Core::RakeTask.new('spec')
7
+
8
+ task :default => :spec
data/acts_as_span.gemspec CHANGED
@@ -1,23 +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{start_date and end_date as a span w/ ActiveRecord}
13
+ s.description = 'ActiveRecord model w/ a start_date and an end_date == ActsAsSpan'
14
+ s.license = 'MIT'
13
15
 
14
- s.rubyforge_project = "acts_as_span"
16
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
17
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
18
+ if s.respond_to?(:metadata)
19
+ s.metadata['allowed_push_host'] = 'https://rubygems.org'
20
+ else
21
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
22
+ 'public gem pushes.'
23
+ end
15
24
 
16
25
  s.files = `git ls-files`.split("\n")
17
26
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
- s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
19
- s.require_paths = ["lib"]
20
-
21
- s.add_dependency(%q<activerecord>, [">= 3"])
22
- s.add_dependency('activesupport')
27
+ s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
28
+ s.require_paths = %w[lib]
29
+
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'
37
+
38
+ s.add_runtime_dependency('activerecord', '>= 5.0.0')
39
+ s.add_runtime_dependency('activesupport', '>= 5.0.0')
23
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
@@ -1,161 +1,132 @@
1
- require 'active_support'
2
1
  require 'ostruct'
3
- require 'forwardable'
2
+ require 'acts_as_span/version'
3
+ require 'acts_as_span/span_klass'
4
+ require 'acts_as_span/span_instance'
5
+
6
+ require 'acts_as_span/no_overlap_validator'
7
+ require 'acts_as_span/within_parent_date_span_validator'
4
8
 
5
- ACTS_AS_SPAN_PATH = File.dirname(__FILE__) + "/acts_as_span/"
9
+ require 'acts_as_span/end_date_propagator'
6
10
 
7
- require ACTS_AS_SPAN_PATH + 'version'
8
- require ACTS_AS_SPAN_PATH + 'span_klass'
9
- require ACTS_AS_SPAN_PATH + 'span_instance'
11
+ require 'active_support'
12
+ require 'active_record'
13
+
14
+ I18n.load_path += Dir[File.join(File.dirname(__dir__), 'config', 'locales', '**', 'acts_as_span.yml')]
10
15
 
11
16
  module ActsAsSpan
12
17
  extend ActiveSupport::Concern
13
-
18
+
19
+ OPTIONS = %i[start_field end_field name].freeze
20
+
14
21
  class << self
15
22
  def options
16
23
  @options ||= {
17
- :start_date_field => :start_date,
18
- :end_date_field => :end_date,
19
- :start_date_field_required => false,
20
- :end_date_field_required => false,
21
- :span_overlap_scope => nil,
22
- :span_overlap_count => nil,
24
+ :start_field => :start_date,
25
+ :end_field => :end_date,
23
26
  :name => :default
24
27
  }
25
28
  end
26
-
29
+
27
30
  def configure
28
31
  yield(self) if block_given?
29
32
  end
30
33
  end
31
-
32
- #by default, all model classess & their instances will return false w/ acts_as_span?
33
- included do
34
- self.send(:extend, ActsAsSpan::NegativeMethods)
35
- self.send(:include, ActsAsSpan::NegativeMethods)
36
- end
37
-
34
+
38
35
  module ClassMethods
39
36
  def acts_as_span(*args)
40
- #this model & its instances will return true w/ acts_as_span?
41
- self.send(:extend, ActsAsSpan::PositiveMethods)
42
- self.send(:include, ActsAsSpan::PositiveMethods)
43
-
44
- self.send(:extend, Forwardable)
45
-
46
37
  self.send(:extend, ActsAsSpan::ExtendedClassMethods)
47
38
  self.send(:include, ActsAsSpan::IncludedInstanceMethods)
48
-
39
+
40
+ # TODO: There's some refactoring that could be done here using keyword args (or the more standard old hash arg pattern)
49
41
  options = OpenStruct.new(args.last.is_a?(Hash) ? ActsAsSpan.options.merge(args.pop) : ActsAsSpan.options)
50
-
51
- #if span_overlap_scope is specified the span_overlap_count defaults to 0
52
- options.span_overlap_count ||= 0 if options.span_overlap_scope
53
-
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
+
54
51
  acts_as_span_definitions[options.name] = options
55
-
56
- def_delegators :span, :close!,
57
- :close_on!,
58
- :span_status,
59
- :span_status_on,
60
- :span_status_to_s,
61
- :span_status_to_s_on,
62
- :current?,
63
- :current_on?,
64
- :future?,
65
- :future_on?,
66
- :expired?,
67
- :expired_on?
68
-
69
- def_delegators 'self.class', :acts_as_span_definitions
70
-
52
+
53
+ # TODO add tests that check delegation of all methos in span
54
+ delegate :span_status,
55
+ :span_status_on,
56
+ :current?,
57
+ :current_on?,
58
+ :future?,
59
+ :future_on?,
60
+ :expired?,
61
+ :expired_on?,
62
+ :past?,
63
+ :past_on?, to: :span
64
+
65
+ delegate :acts_as_span_definitions, to: :class
66
+
67
+ # TODO idem above
71
68
  class << self
72
- self.send(:extend, Forwardable)
73
-
74
- def_delegators :span, :current,
75
- :current_on,
76
- :future,
77
- :future_on,
78
- :expired,
79
- :expired_on
69
+ delegate :current,
70
+ :current_on,
71
+ :future,
72
+ :future_on,
73
+ :expired,
74
+ :expired_on,
75
+ :past_on,
76
+ :past,
77
+ :current_or_future_on,
78
+ :current_or_future, to: :span
80
79
  end
81
-
80
+
82
81
  validate :validate_spans
83
82
  end
84
-
83
+
85
84
  def acts_as_span_definitions
86
85
  @_acts_as_span_definitions ||= {}
87
86
  end
88
87
  end
89
-
88
+
90
89
  module ExtendedClassMethods
91
- def overlap(test_record)
92
- overlap_for(test_record, :default, :default)
93
- end
94
-
95
- def overlap_for(test_record, test_record_span_name = :default, this_span_name = :default)
96
- span_for(this_span_name).overlap(test_record.span_for(test_record_span_name))
97
- end
98
-
99
90
  def spans
100
91
  acts_as_span_definitions.keys.map { |acts_as_span_definition_name| span_for(acts_as_span_definition_name) }
101
92
  end
102
-
93
+
103
94
  def span
104
95
  span_for(:default)
105
96
  end
106
-
97
+
107
98
  def span_for(name = :default)
108
99
  acts_as_span_klasses[name] ||= SpanKlass.new(name, self, acts_as_span_definitions[name])
109
100
  end
110
-
101
+
111
102
  def acts_as_span_klasses
112
103
  @_acts_as_span_klasses ||= {}
113
104
  end
114
105
  end
115
-
106
+
116
107
  module IncludedInstanceMethods
117
108
  def spans
118
109
  acts_as_span_definitions.keys.map { |acts_as_span_definition_name| span_for(acts_as_span_definition_name) }
119
110
  end
120
-
111
+
121
112
  def span
122
113
  span_for(:default)
123
114
  end
124
-
115
+
125
116
  def span_for(name = :default)
126
117
  acts_as_span_instances[name] ||= SpanInstance.new(name, self, acts_as_span_definitions[name])
127
118
  end
128
-
119
+
129
120
  def acts_as_span_instances
130
121
  @_acts_as_span_instances ||= {}
131
122
  end
132
-
123
+
133
124
  def validate_spans
134
125
  spans.each(&:validate)
135
126
  end
136
-
137
- #This syntax assumes :default span
138
- def overlap?(other_record)
139
- overlap_for?(other_record, :default, :default)
140
- end
141
-
142
- #record.span_for(:this_span_name).overlap?(other_record.span_for(:other_record_span_name))
143
- def overlap_for?(other_record, this_span_name = :default, other_record_span_name = :default)
144
- span_for(this_span_name).overlap?(other_record.span_for(other_record_span_name))
145
- end
146
- end
147
-
148
- module PositiveMethods
149
- send(:define_method, "acts_as_span?") do
150
- true
151
- end
152
- end
153
-
154
- module NegativeMethods
155
- send(:define_method, "acts_as_span?") do
156
- false
157
- end
158
127
  end
159
128
  end
160
129
 
161
- ActiveRecord::Base.send(:include, ActsAsSpan)
130
+ if Object.const_defined?("ActiveRecord")
131
+ ActiveRecord::Base.send(:include, ActsAsSpan)
132
+ end
@@ -0,0 +1,198 @@
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
+ unless object.errors[error].include? message
93
+ object.errors.add(error, message: message)
94
+ end
95
+ end
96
+ object
97
+ end
98
+
99
+ private
100
+
101
+ def propagate
102
+ # return if there is nothing to propagate
103
+ return object unless should_propagate_from? object
104
+
105
+ children(object).each do |child|
106
+ # End the record, its children too. And their children, forever, true.
107
+ propagated_child = assign_end_date(child, object.span.end_date)
108
+
109
+ # save child and add errors to cache
110
+ save_with_errors(object, child, propagated_child)
111
+ end
112
+
113
+ if errors_cache.present?
114
+ errors_cache.each do |message|
115
+ skip if object.errors.added?(:base, message)
116
+
117
+ object.errors.add(:base, message: message)
118
+ end
119
+ end
120
+
121
+ # return the object, with any newly-added errors
122
+ object
123
+ end
124
+
125
+ # returns the given child, but possibly with errors
126
+ def assign_end_date(child, new_end_date)
127
+ child.assign_attributes({ child.span.end_field => new_end_date })
128
+ ActsAsSpan::EndDatePropagator.call(
129
+ child,
130
+ errors_cache: errors_cache,
131
+ skipped_classes: skipped_classes,
132
+ )
133
+ end
134
+
135
+ # save the child record, add errors.
136
+ def save_with_errors(object, child, propagated_child)
137
+ if object_has_errors?(propagated_child) && include_errors
138
+ errors_cache << propagation_error_message(object, child)
139
+ end
140
+ child.save
141
+ end
142
+
143
+ def propagation_error_message(object, child)
144
+ I18n.t(
145
+ 'propagation_failure',
146
+ scope: %i[activerecord errors messages end_date_propagator],
147
+ end_date_field_name: child.class.human_attribute_name(
148
+ child.span.end_field,
149
+ ),
150
+ parent: object.model_name.human,
151
+ child: child.model_name.human,
152
+ reason: child.errors.full_messages.join('; '),
153
+ )
154
+ end
155
+
156
+ def object_has_errors?(object)
157
+ !object.valid? ||
158
+ (object.errors.present? && object.errors.messages.values.flatten.any?)
159
+ end
160
+
161
+ # check if the end_date analog is dirtied
162
+ def end_date_changed?(object)
163
+ end_date_field = object.span.end_field.to_s
164
+ object.changed.include? end_date_field
165
+ end
166
+
167
+ def should_propagate_from?(object)
168
+ object.respond_to?(:span) &&
169
+ end_date_changed?(object) &&
170
+ !object.span.end_date.nil?
171
+ end
172
+
173
+ # Use acts_as_span to determine whether a record has an end date
174
+ def should_propagate_to?(klass)
175
+ klass.respond_to?(:span) && @skipped_classes.exclude?(klass)
176
+ end
177
+
178
+ def child_associations(object)
179
+ object.class.reflect_on_all_associations(:has_many).select do |reflection|
180
+ %i[delete destroy].include?(reflection.options[:dependent]) &&
181
+ should_propagate_to?(reflection.klass)
182
+ end
183
+ end
184
+
185
+ def children(object)
186
+ child_objects = child_associations(object).flat_map do |reflection|
187
+ object.send(reflection.name)
188
+ end
189
+
190
+ # skip previously-ended children
191
+ child_objects.reject do |child|
192
+ child.span.end_date && child.span.end_date < object.span.end_date
193
+ end
194
+ end
195
+
196
+ attr_writer :object, :errors_cache
197
+ end
198
+ end