acts_as_span 0.0.5 → 1.2.0

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.
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
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
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
+ #
34
+ class NoOverlapValidator < ActiveModel::Validator
35
+ def validate(record)
36
+ overlapping_records = temporally_overlapping_for(record)
37
+ instance_scope = if options[:instance_scope].is_a? Proc
38
+ record.instance_eval(&options[:instance_scope])
39
+ else
40
+ true
41
+ end
42
+
43
+ return unless overlapping_records.any? && instance_scope
44
+
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
+ )
56
+ end
57
+
58
+ # TODO: add back condition for start_date nil
59
+ # TODO: add support for multiple spans (currently only checks :default)
60
+ def temporally_overlapping_for(record)
61
+ scope = record.instance_eval(&options[:scope])
62
+
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
+
68
+ arel_table = record.class.arel_table
69
+
70
+ if end_date
71
+ scope.where(
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))
76
+ )
77
+ )
78
+ else
79
+ scope.where(
80
+ arel_table[end_field].gteq(start_date)
81
+ .or(arel_table[end_field].eq(nil))
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
@@ -1,53 +1,45 @@
1
- require 'forwardable'
1
+ require 'acts_as_span/span_instance/validations'
2
+ require 'acts_as_span/span_instance/status'
2
3
 
3
- require ACTS_AS_SPAN_PATH + 'span_instance/validations'
4
- require ACTS_AS_SPAN_PATH + 'span_instance/status'
5
- require ACTS_AS_SPAN_PATH + 'span_instance/overlap'
4
+ require 'active_support/core_ext/module/delegation'
6
5
 
7
6
  module ActsAsSpan
8
7
  class SpanInstance
9
- extend Forwardable
10
-
11
8
  include ActsAsSpan::SpanInstance::Validations
12
9
  include ActsAsSpan::SpanInstance::Status
13
- include ActsAsSpan::SpanInstance::Overlap
14
-
15
- def_delegators :@acts_as_span_definition, :start_date_field,
16
- :end_date_field,
17
- :start_date_field_required,
18
- :end_date_field_required,
19
- :exclude_end,
20
- :span_overlap_scope,
21
- :span_overlap_count
22
-
23
- def_delegators :span_model, :new_record?
24
-
10
+
11
+ delegate :start_field,
12
+ :end_field,
13
+ :exclude_end, to: :@acts_as_span_definition
14
+
15
+ delegate :new_record?, to: :span_model
16
+
25
17
  attr_reader :name, :span_model, :acts_as_span_definition
26
-
18
+
27
19
  def initialize(name, span_model, acts_as_span_definition)
28
20
  @name = name
29
21
  @span_model = span_model
30
22
  @acts_as_span_definition = acts_as_span_definition
31
23
  end
32
-
24
+
33
25
  def span_klass
34
26
  @span_klass ||= span_model.class
35
27
  end
36
-
28
+
37
29
  def start_date
38
- span_model[start_date_field]
30
+ span_model[start_field]
39
31
  end
40
-
32
+
41
33
  def end_date
42
- span_model[end_date_field]
34
+ span_model[end_field]
43
35
  end
44
-
45
- def close!(close_date = Date.today)
46
- if end_date.blank?
47
- span_model.update_attributes!(end_date_field => close_date)
48
- end
36
+
37
+ def start_date_changed?
38
+ span_model.will_save_change_to_attribute?(start_field)
39
+ end
40
+
41
+ def end_date_changed?
42
+ span_model.will_save_change_to_attribute?(end_field)
49
43
  end
50
-
51
- alias_method :close_on!, :close!
52
44
  end
53
- end
45
+ end
@@ -1,12 +1,10 @@
1
- require 'active_support'
2
-
3
1
  module ActsAsSpan
4
2
  class SpanInstance
5
3
  module Status
6
4
  extend ActiveSupport::Concern
7
-
8
- module InstanceMethods
9
- def span_status(query_date = Date.today)
5
+
6
+ included do
7
+ def span_status(query_date = Date.current)
10
8
  if future?(query_date)
11
9
  :future
12
10
  elsif expired?(query_date)
@@ -20,39 +18,26 @@ module ActsAsSpan
20
18
 
21
19
  alias_method :span_status_on, :span_status
22
20
 
23
- def span_status_to_s(query_date = Date.today)
24
- case span_status(query_date)
25
- when :future
26
- "Future"
27
- when :expired
28
- "Expired"
29
- when :current
30
- "Current"
31
- when :unknown
32
- "Unknown"
33
- end
34
- end
35
-
36
- alias_method :span_status_to_s_on, :span_status_to_s
37
-
38
- def current?(query_date = Date.today)
21
+ def current?(query_date = Date.current)
39
22
  !future?(query_date) && !expired?(query_date)
40
23
  end
41
-
24
+
42
25
  alias_method :current_on?, :current?
43
26
 
44
- def future?(query_date = Date.today)
27
+ def future?(query_date = Date.current)
45
28
  start_date && start_date > query_date
46
29
  end
47
-
30
+
48
31
  alias_method :future_on?, :future?
49
32
 
50
- def expired?(query_date = Date.today)
33
+ def expired?(query_date = Date.current)
51
34
  end_date && end_date < query_date
52
35
  end
53
-
36
+
54
37
  alias_method :expired_on?, :expired?
38
+ alias_method :past_on?, :expired?
39
+ alias_method :past?, :expired?
55
40
  end
56
41
  end
57
42
  end
58
- end
43
+ end
@@ -1,67 +1,25 @@
1
- require 'active_support'
2
-
3
1
  module ActsAsSpan
4
2
  class SpanInstance
5
3
  module Validations
6
4
  extend ActiveSupport::Concern
7
-
8
- module InstanceMethods
5
+
6
+ included do
9
7
  def validate
10
- validate_start_date
11
- validate_end_date
12
8
  validate_start_date_less_than_or_equal_to_end_date
13
- validate_overlap
14
- end
15
-
16
- def validate_start_date
17
- if start_date_field_required && start_date.blank?
18
- span_model.errors.add(start_date_field, :blank)
19
- end
20
9
  end
21
-
22
- def validate_end_date
23
- if end_date_field_required && end_date.blank?
24
- span_model.errors.add(end_date_field, :blank)
25
- end
26
- end
27
-
10
+
28
11
  def validate_start_date_less_than_or_equal_to_end_date
29
12
  if start_date && end_date && end_date < start_date
30
- span_model.errors.add(end_date_field, "Must be on or after #{start_date_field}")
31
- end
32
- end
33
-
34
- def validate_overlap
35
- if span_overlap_count && span_model.errors[start_date_field].empty? && span_model.errors[end_date_field].empty? # && ( respond_to?('archived?') ? !archived? : true )
36
- conditions = {}
37
-
38
- if span_overlap_scope.is_a?(Array)
39
- span_overlap_scope.each do |symbol|
40
- conditions[symbol] = span_model.send(symbol)
41
- end
42
- elsif span_overlap_scope.is_a?(Symbol)
43
- conditions[span_overlap_scope] = span_model.send(span_overlap_scope)
44
- end
45
-
46
- records = span_klass.span_for(name).overlap(self).where(conditions)
47
-
48
- if span_klass.respond_to?('not_archived')
49
- records.not_archived
50
- end
51
-
52
- #TODO - This will have to be an after_save callback...
53
- #if span_overlap_auto_close
54
- # records.each do |record|
55
- # record.close!(start_date)
56
- # end
57
- #end
58
-
59
- if records.count > span_overlap_count
60
- span_model.errors.add(:base, "date range overlaps with #{records.count} other record(s)")
61
- end
13
+ span_model.errors.add(
14
+ end_field,
15
+ :start_date_after_end_date,
16
+ start_field: span_model.class.human_attribute_name(
17
+ span_model.span.start_field
18
+ )
19
+ )
62
20
  end
63
21
  end
64
22
  end
65
23
  end
66
24
  end
67
- end
25
+ end
@@ -1,31 +1,23 @@
1
- require 'forwardable'
1
+ require 'acts_as_span/span_klass/status'
2
2
 
3
- require ACTS_AS_SPAN_PATH + 'span_klass/status'
4
- require ACTS_AS_SPAN_PATH + 'span_klass/overlap'
3
+ require 'active_support/core_ext/module/delegation'
5
4
 
6
5
  module ActsAsSpan
7
6
  class SpanKlass
8
- extend Forwardable
9
-
10
7
  include ActsAsSpan::SpanKlass::Status
11
- include ActsAsSpan::SpanKlass::Overlap
12
-
13
- def_delegators :@acts_as_span_definition, :start_date_field,
14
- :end_date_field,
15
- :start_date_field_required,
16
- :end_date_field_required,
17
- :exclude_end,
18
- :span_overlap_scope,
19
- :span_overlap_count
20
-
21
- def_delegators :klass, :table_name
22
-
8
+
9
+ delegate :start_field,
10
+ :end_field,
11
+ :exclude_end, to: :@acts_as_span_definition
12
+
13
+ delegate :table_name, :arel_table, to: :klass, allow_nil: true
14
+
23
15
  attr_reader :name, :klass, :acts_as_span_definition
24
-
16
+
25
17
  def initialize(name, klass, acts_as_span_definition)
26
18
  @name = name
27
19
  @klass = klass
28
20
  @acts_as_span_definition = acts_as_span_definition
29
21
  end
30
22
  end
31
- end
23
+ end
@@ -4,26 +4,57 @@ module ActsAsSpan
4
4
  class SpanKlass
5
5
  module Status
6
6
  extend ActiveSupport::Concern
7
-
8
- module InstanceMethods
9
- def current(query_date = Date.today)
10
- klass.where(["(#{table_name}.#{start_date_field} <= :query_date OR #{table_name}.#{start_date_field} IS NULL) AND (#{table_name}.#{end_date_field} >= :query_date OR #{table_name}.#{end_date_field} IS NULL)", { :query_date => query_date } ] )
7
+
8
+ included do
9
+ def current(query_date = Date.current)
10
+ klass.where(
11
+ current_condition(query_date: query_date, table: arel_table)
12
+ )
11
13
  end
12
-
14
+
13
15
  alias_method :current_on, :current
14
16
 
15
- def future(query_date = Date.today)
16
- klass.where(["#{table_name}.#{start_date_field} > :query_date", { :query_date => query_date } ] )
17
+ def future(query_date = Date.current)
18
+ klass.where(arel_table[start_field].gt(query_date))
17
19
  end
18
-
20
+
19
21
  alias_method :future_on, :future
20
22
 
21
- def expired(query_date = Date.today)
22
- klass.where(["#{table_name}.#{end_date_field} < :query_date", { :query_date => query_date } ] )
23
+ def expired(query_date = Date.current)
24
+ klass.where(arel_table[end_field].lt(query_date))
23
25
  end
24
-
26
+
25
27
  alias_method :expired_on, :expired
28
+ alias_method :past_on, :expired
29
+ alias_method :past, :expired
30
+
31
+
32
+ def current_or_future_on(query_date = Date.current)
33
+ klass.where(
34
+ arel_table[start_field].lteq(query_date).
35
+ and(
36
+ arel_table[end_field].eq(nil).
37
+ or(arel_table[end_field].gteq(query_date))
38
+ ).
39
+ or(arel_table[start_field].gt(query_date))
40
+ )
41
+ end
42
+
43
+ alias_method :current_or_future, :current_or_future_on
44
+
45
+ private
46
+
47
+ # returns an Arel node usable within an ActiveRecord `where` clause
48
+ def current_condition(query_date:, table:)
49
+ start_col = arel_table[start_field]
50
+ end_col = arel_table[end_field]
51
+
52
+ start_condition = start_col.lteq(query_date).or(start_col.eq(nil))
53
+ end_condition = end_col.eq(nil).or(end_col.gteq(query_date))
54
+
55
+ start_condition.and(end_condition)
56
+ end
26
57
  end
27
58
  end
28
59
  end
29
- end
60
+ end
@@ -1,12 +1,14 @@
1
+ # frozen_string_Literal: true
2
+
1
3
  module ActsAsSpan
2
4
  module VERSION
3
- MAJOR = 0
4
- MINOR = 0
5
- TINY = 5
5
+ MAJOR = 1
6
+ MINOR = 2
7
+ TINY = 0
6
8
  PRE = nil
7
9
 
8
10
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.')
9
-
11
+
10
12
  SUMMARY = "acts_as_span #{STRING}"
11
13
  end
12
14
  end
@@ -0,0 +1,44 @@
1
+ module ActsAsSpan
2
+ class WithinParentDateSpanValidator < ActiveModel::Validator
3
+ def validate(record)
4
+ parents = options[:parent] || options[:parents]
5
+
6
+ error_message = options[:message] || :not_within_parent_date_span
7
+
8
+ Array(parents).each do |parent|
9
+ record.errors.add(:base, error_message, parent: record.class.human_attribute_name(parent)) if outside_of_parent_date_span?(record, parent)
10
+ end
11
+ end
12
+
13
+ def outside_of_parent_date_span?(record, parent_sym)
14
+ parent = record.send(parent_sym)
15
+
16
+ return false if parent.nil?
17
+
18
+ child_record_without_start_date(record, parent) ||
19
+ child_record_without_end_date(record, parent) ||
20
+ child_record_started_before_parent_record(record, parent) ||
21
+ child_record_ended_after_parent_record(record, parent)
22
+ end
23
+
24
+ private
25
+
26
+ def child_record_started_before_parent_record(record, parent)
27
+ record.span.start_date.present? && parent.span.start_date.present? &&
28
+ record.span.start_date < parent.span.start_date
29
+ end
30
+
31
+ def child_record_ended_after_parent_record(record, parent)
32
+ record.span.end_date.present? && parent.span.end_date.present? &&
33
+ record.span.end_date > parent.span.end_date
34
+ end
35
+
36
+ def child_record_without_start_date(record, parent)
37
+ record.span.start_date.nil? && parent.span.start_date.present?
38
+ end
39
+
40
+ def child_record_without_end_date(record, parent)
41
+ record.span.end_date.nil? && parent.span.end_date.present?
42
+ end
43
+ end
44
+ end