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.
- checksums.yaml +7 -0
- data/.gitignore +11 -3
- data/.rspec +2 -0
- data/.tool-versions +1 -0
- data/.travis.yml +12 -0
- data/Gemfile +0 -3
- data/README.rdoc +4 -16
- data/Rakefile +5 -1
- data/acts_as_span.gemspec +31 -14
- data/config/locales/en/acts_as_span.yml +15 -0
- data/lib/acts_as_span.rb +69 -98
- data/lib/acts_as_span/end_date_propagator.rb +198 -0
- data/lib/acts_as_span/no_overlap_validator.rb +86 -0
- data/lib/acts_as_span/span_instance.rb +24 -32
- data/lib/acts_as_span/span_instance/status.rb +12 -27
- data/lib/acts_as_span/span_instance/validations.rb +11 -53
- data/lib/acts_as_span/span_klass.rb +11 -19
- data/lib/acts_as_span/span_klass/status.rb +43 -12
- data/lib/acts_as_span/version.rb +6 -4
- data/lib/acts_as_span/within_parent_date_span_validator.rb +44 -0
- data/spec/lib/acts_as_span_spec.rb +38 -35
- data/spec/lib/delegation_spec.rb +45 -78
- data/spec/lib/end_date_propagator_spec.rb +319 -0
- data/spec/lib/no_overlap_validator_spec.rb +129 -0
- data/spec/lib/span_instance/named_scopes_on_spec.rb +193 -193
- data/spec/lib/span_instance/named_scopes_spec.rb +193 -191
- data/spec/lib/span_instance/overlap_spec.rb +193 -253
- data/spec/lib/span_instance/status_spec.rb +22 -35
- data/spec/lib/span_instance/validations_spec.rb +8 -44
- data/spec/lib/span_instance_spec.rb +17 -30
- data/spec/lib/span_klass/status_spec.rb +38 -0
- data/spec/lib/within_parent_date_span_validator_spec.rb +126 -0
- data/spec/spec_helper.rb +19 -6
- data/spec/spec_models.rb +226 -0
- metadata +167 -61
- data/Gemfile.lock +0 -47
- data/lib/acts_as_span/span_instance/overlap.rb +0 -17
- data/lib/acts_as_span/span_klass/overlap.rb +0 -21
- data/spec/lib/negative_spec.rb +0 -30
- 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 '
|
1
|
+
require 'acts_as_span/span_instance/validations'
|
2
|
+
require 'acts_as_span/span_instance/status'
|
2
3
|
|
3
|
-
require
|
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
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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[
|
30
|
+
span_model[start_field]
|
39
31
|
end
|
40
|
-
|
32
|
+
|
41
33
|
def end_date
|
42
|
-
span_model[
|
34
|
+
span_model[end_field]
|
43
35
|
end
|
44
|
-
|
45
|
-
def
|
46
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
9
|
-
def span_status(query_date = Date.
|
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
|
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.
|
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.
|
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
|
-
|
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(
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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 '
|
1
|
+
require 'acts_as_span/span_klass/status'
|
2
2
|
|
3
|
-
require
|
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
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
9
|
-
def current(query_date = Date.
|
10
|
-
klass.where(
|
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.
|
16
|
-
klass.where([
|
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.
|
22
|
-
klass.where([
|
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
|
data/lib/acts_as_span/version.rb
CHANGED
@@ -1,12 +1,14 @@
|
|
1
|
+
# frozen_string_Literal: true
|
2
|
+
|
1
3
|
module ActsAsSpan
|
2
4
|
module VERSION
|
3
|
-
MAJOR =
|
4
|
-
MINOR =
|
5
|
-
TINY =
|
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
|