state_validations 0.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.
- data/.gitignore +20 -0
- data/Gemfile +2 -0
- data/LICENSE +22 -0
- data/README.md +135 -0
- data/Rakefile +41 -0
- data/lib/active_model/validations/helper_methods.rb +24 -0
- data/lib/active_model/validations/state_history_entry_validator.rb +40 -0
- data/lib/active_model/validations/state_history_validator.rb +118 -0
- data/lib/state_validations.rb +6 -0
- data/state_validations.gemspec +28 -0
- data/test/test_helper.rb +8 -0
- data/test/unit/helper_methods_test.rb +45 -0
- data/test/unit/state_history_entry_validator_test.rb +101 -0
- data/test/unit/state_history_validator_test.rb +308 -0
- metadata +171 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 PagerDuty, Inc.
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,135 @@
|
|
1
|
+
# StateValidations
|
2
|
+
|
3
|
+
**Current version:** 0.2.0
|
4
|
+
|
5
|
+
The StateValidations gem is a collection of two Validators that aid in
|
6
|
+
verifying the integrity of a state history association. As defined here, a
|
7
|
+
state history is a collection of entries, each with start and end time
|
8
|
+
attributes.
|
9
|
+
|
10
|
+
One validator provided is the aptly-named StateHistoryValidator. This Validator
|
11
|
+
examines a state history holistically checking for a variety of issues. These
|
12
|
+
issues include:
|
13
|
+
|
14
|
+
* Overlaps: the time ranges of two consecutive entries overlap
|
15
|
+
* Gaps: the time ranges of two consecutive entries are not continuous
|
16
|
+
* Self-transitions: two continuous consecutive entries are equivalent in state
|
17
|
+
* Simultaneous states: an entry that is not the last entry has not ended (i.e.
|
18
|
+
the entry's end time is `nil`)
|
19
|
+
* Ended state histories: the last entry has ended (i.e. last entry's end time
|
20
|
+
is `nil`)
|
21
|
+
|
22
|
+
The user of this validator can disable any combination of these checks as he or
|
23
|
+
she wishes through the `:allow` option.
|
24
|
+
|
25
|
+
The other validator provided is the StateHistoryEntryValidator. This Validator
|
26
|
+
examines each state history entry on its own, checking for these issues:
|
27
|
+
|
28
|
+
* Zero-length entries: the entry begins and ends at the same time
|
29
|
+
* Ends before starting: the entry ends before it starts (end < start)
|
30
|
+
|
31
|
+
As with the first validator, the user can disable any combination of these
|
32
|
+
checks.
|
33
|
+
|
34
|
+
## Installation
|
35
|
+
|
36
|
+
Add this line to your application's Gemfile:
|
37
|
+
|
38
|
+
gem 'state_validations'
|
39
|
+
|
40
|
+
And then execute:
|
41
|
+
|
42
|
+
$ bundle
|
43
|
+
|
44
|
+
Or install it yourself as:
|
45
|
+
|
46
|
+
$ git clone git://github.com/PagerDuty/state-history-validator.git
|
47
|
+
$ rake build
|
48
|
+
$ gem install pkg/state_validations-(version).gem
|
49
|
+
|
50
|
+
## Usage
|
51
|
+
|
52
|
+
### Examples
|
53
|
+
|
54
|
+
#### StateHistoryValidator
|
55
|
+
Let's say that we want to check the integrity of `:state_history_entries`.
|
56
|
+
This is how we might call the validator:
|
57
|
+
|
58
|
+
validate_state_history :state_history_entries,
|
59
|
+
:self_transition_match => [:state_id],
|
60
|
+
:allow => [:gaps]
|
61
|
+
|
62
|
+
Here, our validator will allow for gaps in the state history entries. It
|
63
|
+
will look for self-transitions depending on whether the `:state_id`
|
64
|
+
attribute of two consecutive entries match.
|
65
|
+
|
66
|
+
#### StateHistoryEntryValidator
|
67
|
+
Let's say we want to validate an entry, but we don't particularly care
|
68
|
+
about zero-entries. In addition, our entry uses the attribute `:begin` to
|
69
|
+
indicate its start time attribute. This is how we might use the validator:
|
70
|
+
|
71
|
+
validate_state_history_entry
|
72
|
+
:start => :begin
|
73
|
+
:allow => [:zero_duration]
|
74
|
+
|
75
|
+
Note that we omitted the comma after `validate_state_history_entry`.
|
76
|
+
|
77
|
+
The StateHistoryEntryValidator requires that the receiving class validate
|
78
|
+
for the presence of the `:start` attribute. The `validate_state_history_entry`
|
79
|
+
construct, which is the recommended way to call this validator, performs this
|
80
|
+
validation automatically.
|
81
|
+
|
82
|
+
### Interface
|
83
|
+
With these examples in mind, here is the full documentation on every option
|
84
|
+
that can be toggled.
|
85
|
+
|
86
|
+
#### StateHistoryValidator
|
87
|
+
* `:start` -- (default = `:start`) Specifies the attribute referring to the
|
88
|
+
starting time of each entry
|
89
|
+
* `:end` -- (default = `:end`) Specifies the attribute referring to the
|
90
|
+
ending time of each entry
|
91
|
+
* `:order` -- (default = `#{start} ASC, ISNULL(#{end}) ASC, #{end} ASC`)
|
92
|
+
Specifies the SQL order by which the validator will sort all entries in the
|
93
|
+
state history. This is essential in ensuring that the validator obtains the
|
94
|
+
correct chronological order of entries so that it can properly check for
|
95
|
+
issues. By default, the validator sorts in ascending order of starting
|
96
|
+
time. If the start times for two entries are equal, it will then sort by
|
97
|
+
end times (pushing `nil` ends to the bottom).
|
98
|
+
* `:self_transition_match` -- (default = `[]`) Specifies the attributes to
|
99
|
+
check two states for equality. This is used when checking for self-transitions.
|
100
|
+
If self-transitions are allowed, this option has no effect. If this option
|
101
|
+
is left to its default value of an empty array, the validator will not check
|
102
|
+
for self-transitions.
|
103
|
+
* `:allow` -- (default = `[]`) Specifies issues to be ignored by the validator.
|
104
|
+
This is specified as a list of issues. By default, all checks are performed.
|
105
|
+
The issues that can be specified are:
|
106
|
+
* `:gaps` -- Ignore gaps
|
107
|
+
* `:overlaps` -- Ignore overlaps
|
108
|
+
* `:self_transitions` -- Ignore two consecutive equal states. As a side
|
109
|
+
note, two equal states separated by a gap are not considered to be in
|
110
|
+
self-transition.
|
111
|
+
* `:active_middle_states` -- Ignore the presence of an active state in the
|
112
|
+
middle of a state history. An active state is defined by the state's `:end`
|
113
|
+
attribute being `nil`.
|
114
|
+
This excludes the final state.
|
115
|
+
* `:inactive_end_state` -- Ignore the lack of a final active ending state.
|
116
|
+
Using our earlier definition, this means that the last entry does not have
|
117
|
+
`nil` as the value of its `:end` attribute.
|
118
|
+
|
119
|
+
#### StateHistoryEntryValidator
|
120
|
+
* `:start` -- (default = `:start`) Specifies the attribute referring to the
|
121
|
+
starting time of this entry
|
122
|
+
* `:end` -- (default = `:end`) Specifies the attribute referring to the ending
|
123
|
+
time of this entry
|
124
|
+
* `:allow` -- (default = `[]`) Specifies issues to be ignored by the validator.
|
125
|
+
* `:zero_duration` -- Ignore start time equal to end time
|
126
|
+
* `:end_before_start` -- Ignore end time less than start time
|
127
|
+
|
128
|
+
|
129
|
+
## Contributing
|
130
|
+
|
131
|
+
1. Fork it
|
132
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
133
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
134
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
135
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
require "bundler/gem_tasks"
|
3
|
+
require 'rake/testtask'
|
4
|
+
|
5
|
+
desc 'Run shoulda unit tests'
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
7
|
+
t.libs << 'lib'
|
8
|
+
t.libs << 'test'
|
9
|
+
t.pattern = 'test/**/*_test.rb'
|
10
|
+
t.verbose = true
|
11
|
+
end
|
12
|
+
|
13
|
+
|
14
|
+
# Thanks, jekyll, for having your Rakefile on Github
|
15
|
+
|
16
|
+
def name
|
17
|
+
@name ||= Dir['*.gemspec'].first.split('.').first
|
18
|
+
end
|
19
|
+
|
20
|
+
def version
|
21
|
+
line = File.read(gemspec_file)[/^\s*gem\.version\s*=\s*.*/]
|
22
|
+
line.match(/.*gem\.version\s*=\s*['"](.*)['"]/)[1]
|
23
|
+
end
|
24
|
+
|
25
|
+
def gemspec_file
|
26
|
+
"#{name}.gemspec"
|
27
|
+
end
|
28
|
+
|
29
|
+
def gem_file
|
30
|
+
"#{name}-#{version}.gem"
|
31
|
+
end
|
32
|
+
|
33
|
+
task :build do
|
34
|
+
sh "mkdir -p pkg"
|
35
|
+
sh "gem build #{gemspec_file}"
|
36
|
+
sh "mv #{gem_file} pkg"
|
37
|
+
end
|
38
|
+
|
39
|
+
task :push do
|
40
|
+
sh "gem push pkg/#{gem_file}"
|
41
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'active_model/validations'
|
2
|
+
|
3
|
+
module ActiveModel
|
4
|
+
module Validations
|
5
|
+
|
6
|
+
module HelperMethods
|
7
|
+
def validate_state_history_entry(options = {})
|
8
|
+
start_attr = options[:start] || :start
|
9
|
+
validates start_attr, :presence => true
|
10
|
+
|
11
|
+
validates_with StateHistoryEntryValidator, options
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
def validate_state_history(association, options = {})
|
16
|
+
params = { :association => association }
|
17
|
+
params.merge!(options)
|
18
|
+
validates_with StateHistoryValidator, params
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module ActiveModel
|
2
|
+
module Validations
|
3
|
+
|
4
|
+
class StateHistoryEntryValidator < ActiveModel::Validator
|
5
|
+
def initialize(options = {})
|
6
|
+
super
|
7
|
+
|
8
|
+
@start = options[:start] || :start
|
9
|
+
@end = options[:end] || :end
|
10
|
+
@allow = options[:allow] || []
|
11
|
+
end
|
12
|
+
|
13
|
+
def validate(record)
|
14
|
+
non_zero?(record) unless @allow.include? :zero_duration
|
15
|
+
start_before_end?(record) unless @allow.include? :end_before_start
|
16
|
+
|
17
|
+
return
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def non_zero?(record)
|
23
|
+
start = record[@start]
|
24
|
+
_end = record[@end]
|
25
|
+
if start == _end
|
26
|
+
record.errors[:base] << "Start time (#{start}) is equal to end (#{_end})"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def start_before_end?(record)
|
31
|
+
start = record[@start]
|
32
|
+
_end = record[@end]
|
33
|
+
if !_end.blank? && start > _end
|
34
|
+
record.errors[:base] << "Start time (#{start}) is later than end time (#{_end})"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
module ActiveModel
|
2
|
+
module Validations
|
3
|
+
|
4
|
+
class StateHistoryValidator < ActiveModel::Validator
|
5
|
+
def initialize(options = {})
|
6
|
+
super
|
7
|
+
|
8
|
+
@association = options[:association]
|
9
|
+
|
10
|
+
@start = options[:start] || :start
|
11
|
+
@end = options[:end] || :end
|
12
|
+
@order = options[:order] || "#{@start.to_s} ASC, ISNULL(#{@end}) ASC, #{@end} ASC"
|
13
|
+
@allow = options[:allow] || []
|
14
|
+
@match = options[:self_transition_match] || []
|
15
|
+
end
|
16
|
+
|
17
|
+
def validate(record)
|
18
|
+
entries = record.send(@association)
|
19
|
+
sorted_history = entries.order(@order)
|
20
|
+
|
21
|
+
return if sorted_history.blank?
|
22
|
+
|
23
|
+
if !@allow.include? :inactive_end_state
|
24
|
+
valid = last_end_nil?(sorted_history, record.errors)
|
25
|
+
return unless valid
|
26
|
+
end
|
27
|
+
|
28
|
+
no_self_trans = no_gaps = no_overlaps = no_nils = true
|
29
|
+
sorted_history.each_cons(2) do |a|
|
30
|
+
this_entry = a[0]
|
31
|
+
next_entry = a[1]
|
32
|
+
|
33
|
+
if !@allow.include? :self_transitions
|
34
|
+
no_self_trans = no_self_transitions?(this_entry, next_entry, record.errors)
|
35
|
+
end
|
36
|
+
|
37
|
+
if !@allow.include? :gaps
|
38
|
+
no_gaps = no_gaps?(this_entry, next_entry, record.errors)
|
39
|
+
end
|
40
|
+
|
41
|
+
if !@allow.include? :overlaps
|
42
|
+
no_overlaps = no_overlaps?(this_entry, next_entry, record.errors)
|
43
|
+
end
|
44
|
+
|
45
|
+
if !@allow.include? :active_middle_states
|
46
|
+
no_nils = no_intervening_nils?(this_entry, record.errors)
|
47
|
+
end
|
48
|
+
|
49
|
+
return unless no_self_trans && no_gaps && no_overlaps && no_nils
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def last_end_nil?(history, errors)
|
56
|
+
last = history.last
|
57
|
+
|
58
|
+
if !last[@end].nil?
|
59
|
+
errors[:base] << "no nil ending"
|
60
|
+
return false
|
61
|
+
end
|
62
|
+
|
63
|
+
true
|
64
|
+
end
|
65
|
+
|
66
|
+
def no_self_transitions?(this_entry, next_entry, errors)
|
67
|
+
if matches?(this_entry, next_entry) && this_entry[@end] == next_entry[@start]
|
68
|
+
errors[:base] << "self-transition found"
|
69
|
+
return false
|
70
|
+
end
|
71
|
+
|
72
|
+
true
|
73
|
+
end
|
74
|
+
|
75
|
+
def no_gaps?(this_entry, next_entry, errors)
|
76
|
+
if !this_entry[@end].nil? && next_entry[@start] > this_entry[@end]
|
77
|
+
errors[:base] << "Gap found"
|
78
|
+
return false
|
79
|
+
end
|
80
|
+
|
81
|
+
true
|
82
|
+
end
|
83
|
+
|
84
|
+
def no_overlaps?(this_entry, next_entry, errors)
|
85
|
+
if !this_entry[@end].nil? && next_entry[@start] < this_entry[@end]
|
86
|
+
errors[:base] << "Overlap found"
|
87
|
+
return false
|
88
|
+
end
|
89
|
+
|
90
|
+
true
|
91
|
+
end
|
92
|
+
|
93
|
+
# This doesn't get invoked on the very last entry, so we should be safe
|
94
|
+
def no_intervening_nils?(this_entry, errors)
|
95
|
+
if this_entry[@end].blank?
|
96
|
+
errors[:base] << "Found nil end in middle of state history"
|
97
|
+
return false
|
98
|
+
end
|
99
|
+
|
100
|
+
true
|
101
|
+
end
|
102
|
+
|
103
|
+
# Match two entries based on the specified attributes in @match
|
104
|
+
def matches?(entry1, entry2)
|
105
|
+
return false if @match.size == 0
|
106
|
+
|
107
|
+
@match.each do |attr|
|
108
|
+
return false if entry1[attr] != entry2[attr]
|
109
|
+
end
|
110
|
+
|
111
|
+
true
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
# Note to self: the module file in lib/ needs to have the same name as this file
|
4
|
+
$:.push File.expand_path("../lib", __FILE__)
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.authors = ["Daniel Ge", "PagerDuty, Inc."]
|
8
|
+
gem.email = ["daniel@pagerduty.com"]
|
9
|
+
gem.summary = "State History and State History Entry Validators"
|
10
|
+
gem.description = "A gem that verifies the integrity of a state history"
|
11
|
+
gem.homepage = "https://github.com/PagerDuty/state-history-validator"
|
12
|
+
|
13
|
+
gem.files = `git ls-files`.split($\)
|
14
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
15
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
16
|
+
gem.name = "state_validations"
|
17
|
+
gem.require_paths = ["lib"]
|
18
|
+
gem.version = "0.2.0"
|
19
|
+
|
20
|
+
# Dependencies
|
21
|
+
gem.add_runtime_dependency "activemodel", ">= 3.0.0"
|
22
|
+
gem.add_runtime_dependency "activesupport", ">= 3.0.0"
|
23
|
+
|
24
|
+
gem.add_development_dependency "rake"
|
25
|
+
gem.add_development_dependency "test-unit", "= 2.2"
|
26
|
+
gem.add_development_dependency "mocha"
|
27
|
+
gem.add_development_dependency "shoulda"
|
28
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class HelperMethodsTest < ActiveSupport::TestCase
|
4
|
+
context "The helper methods on a perfectly valid state history" do
|
5
|
+
setup do
|
6
|
+
now = DateTime.new(2011, 2, 3, 5, 8, 13)
|
7
|
+
@starts = [now, now + 2.months, now + 3.months, now + 5.months]
|
8
|
+
@ends = [now + 2.months, now + 3.months, now + 5.months, nil]
|
9
|
+
end
|
10
|
+
|
11
|
+
should "simply work" do
|
12
|
+
record_entries = []
|
13
|
+
@starts.each_with_index do |start, i|
|
14
|
+
_end = @ends[i]
|
15
|
+
id = i
|
16
|
+
entry = RecordEntry.new(start, _end, id)
|
17
|
+
|
18
|
+
assert entry.valid?
|
19
|
+
|
20
|
+
record_entries << entry
|
21
|
+
end
|
22
|
+
|
23
|
+
record_entries.stubs(:order).with("start ASC, ISNULL(end) ASC, end ASC").returns(record_entries)
|
24
|
+
record_entries.stubs(:all).returns(record_entries)
|
25
|
+
|
26
|
+
record = Record.new(record_entries)
|
27
|
+
|
28
|
+
assert record.valid?
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
class Record < Struct.new(:record_entries)
|
35
|
+
include ActiveModel::Validations
|
36
|
+
|
37
|
+
validate_state_history :record_entries,
|
38
|
+
:self_transition_match => [:id]
|
39
|
+
end
|
40
|
+
|
41
|
+
class RecordEntry < Struct.new(:start, :end, :id)
|
42
|
+
include ActiveModel::Validations
|
43
|
+
|
44
|
+
validate_state_history_entry
|
45
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
module ActiveModel
|
4
|
+
module Validations
|
5
|
+
|
6
|
+
class StateHistoryEntryValidatorTest < ActiveSupport::TestCase
|
7
|
+
context "An \"perfect\" account state history entry" do
|
8
|
+
setup do
|
9
|
+
@now = DateTime.new(2011, 2, 3, 5, 8, 13)
|
10
|
+
end
|
11
|
+
|
12
|
+
should "be valid by default with no checks disabled" do
|
13
|
+
entry = ValidatableEntry.new(@now, @now + 1.days)
|
14
|
+
|
15
|
+
validator = StateHistoryEntryValidator.new
|
16
|
+
validator.validate(entry)
|
17
|
+
|
18
|
+
assert !entry.errors.any?
|
19
|
+
end
|
20
|
+
|
21
|
+
should "be valid if :start and :end are changed" do
|
22
|
+
MockEntry = Struct.new(:begin, :finish)
|
23
|
+
MockEntry.class_eval do
|
24
|
+
include ActiveModel::Validations
|
25
|
+
end
|
26
|
+
entry = MockEntry.new(@now, @now + 1.days)
|
27
|
+
|
28
|
+
validator = StateHistoryEntryValidator.new(
|
29
|
+
:start => :begin,
|
30
|
+
:end => :finish
|
31
|
+
)
|
32
|
+
validator.validate(entry)
|
33
|
+
|
34
|
+
assert !entry.errors.any?
|
35
|
+
end
|
36
|
+
|
37
|
+
should "be valid even if the end is nil" do
|
38
|
+
entry = ValidatableEntry.new(@now, nil)
|
39
|
+
|
40
|
+
validator = StateHistoryEntryValidator.new
|
41
|
+
validator.validate(entry)
|
42
|
+
|
43
|
+
assert !entry.errors.any?
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
context "An account state history entry where start == end" do
|
48
|
+
setup do
|
49
|
+
@now = DateTime.new(2011, 2, 3, 5, 8, 13)
|
50
|
+
@entry = ValidatableEntry.new(@now, @now)
|
51
|
+
end
|
52
|
+
|
53
|
+
should "not be valid with no checks disabled" do
|
54
|
+
validator = StateHistoryEntryValidator.new
|
55
|
+
validator.validate(@entry)
|
56
|
+
|
57
|
+
assert @entry.errors.any?
|
58
|
+
end
|
59
|
+
|
60
|
+
should "be valid with :zero_duration allowed" do
|
61
|
+
validator = StateHistoryEntryValidator.new(
|
62
|
+
:allow => [:zero_duration]
|
63
|
+
)
|
64
|
+
validator.validate(@entry)
|
65
|
+
|
66
|
+
assert !@entry.errors.any?
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
context "An account state history entry where start > end" do
|
71
|
+
setup do
|
72
|
+
@now = DateTime.new(2011, 2, 3, 5, 8, 13)
|
73
|
+
@entry = ValidatableEntry.new(@now + 1.days, @now)
|
74
|
+
end
|
75
|
+
|
76
|
+
should "not be valid with no checks disabled" do
|
77
|
+
validator = StateHistoryEntryValidator.new
|
78
|
+
validator.validate(@entry)
|
79
|
+
|
80
|
+
assert @entry.errors.any?
|
81
|
+
end
|
82
|
+
|
83
|
+
should "be valid with :end_before_start allowed" do
|
84
|
+
validator = StateHistoryEntryValidator.new(
|
85
|
+
:allow => [:end_before_start]
|
86
|
+
)
|
87
|
+
validator.validate(@entry)
|
88
|
+
|
89
|
+
assert !@entry.errors.any?
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
class ValidatableEntry < Struct.new(:start, :end)
|
96
|
+
include ActiveModel::Validations
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,308 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
module ActiveModel
|
4
|
+
module Validations
|
5
|
+
|
6
|
+
class StateHistoryValidatorTest < ActiveSupport::TestCase
|
7
|
+
context "A perfectly valid state history" do
|
8
|
+
setup do
|
9
|
+
now = DateTime.new(2011, 2, 3, 5, 8, 13)
|
10
|
+
@starts = [now, now + 2.months, now + 3.months, now + 5.months]
|
11
|
+
@ends = [now + 2.months, now + 3.months, now + 5.months, nil]
|
12
|
+
end
|
13
|
+
|
14
|
+
should "simply be valid with the default options" do
|
15
|
+
state_history = mock_state_history(@starts, @ends) do |entry, i|
|
16
|
+
entry[:other_id] = i + 1
|
17
|
+
end
|
18
|
+
|
19
|
+
record = Validatable.new(state_history)
|
20
|
+
validator = StateHistoryValidator.new(
|
21
|
+
:association => :state_history_entries,
|
22
|
+
:self_transition_match => [:id, :other_id]
|
23
|
+
)
|
24
|
+
validator.validate(record)
|
25
|
+
|
26
|
+
assert !record.errors.any?
|
27
|
+
end
|
28
|
+
|
29
|
+
should "be valid if we only set a different start and end name" do
|
30
|
+
MockEntry = Struct.new(:begin, :finish, :id, :other_id)
|
31
|
+
state_history = mock_state_history(@starts, @ends, nil,
|
32
|
+
{:entry_class => MockEntry,
|
33
|
+
:start => :begin,
|
34
|
+
:end => :finish}) do |entry, i|
|
35
|
+
entry[:other_id] = i + 1
|
36
|
+
end
|
37
|
+
|
38
|
+
record = Validatable.new(state_history)
|
39
|
+
validator = StateHistoryValidator.new(
|
40
|
+
:association => :state_history_entries,
|
41
|
+
:start => :begin,
|
42
|
+
:end => :finish,
|
43
|
+
:self_transition_match => [:id, :other_id]
|
44
|
+
)
|
45
|
+
validator.validate(record)
|
46
|
+
|
47
|
+
assert !record.errors.any?
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
context "A \"perfect\" state history with a self-transition" do
|
53
|
+
setup do
|
54
|
+
now = DateTime.new(2011, 2, 3, 5, 8, 13)
|
55
|
+
starts = [now, now + 2.months, now + 3.months, now + 5.months]
|
56
|
+
ends = [now + 2.months, now + 3.months, now + 5.months, nil]
|
57
|
+
ids = [0, 1, 1, 2]
|
58
|
+
other_id = [1, 1, 1, 2]
|
59
|
+
|
60
|
+
state_history = mock_state_history(starts, ends, ids) do |entry, i|
|
61
|
+
entry[:other_id] = other_id[i]
|
62
|
+
end
|
63
|
+
|
64
|
+
@record = Validatable.new(state_history)
|
65
|
+
end
|
66
|
+
|
67
|
+
should "be invalid with nothing allowed" do
|
68
|
+
validator = StateHistoryValidator.new(
|
69
|
+
:association => :state_history_entries,
|
70
|
+
:self_transition_match => [:id, :other_id]
|
71
|
+
)
|
72
|
+
validator.validate(@record)
|
73
|
+
|
74
|
+
assert @record.errors.any?
|
75
|
+
end
|
76
|
+
|
77
|
+
should "be valid with :self_transitions allowed" do
|
78
|
+
validator = StateHistoryValidator.new(
|
79
|
+
:association => :state_history_entries,
|
80
|
+
:self_transition_match => [:id, :other_id],
|
81
|
+
:allow => [:self_transitions]
|
82
|
+
)
|
83
|
+
validator.validate(@record)
|
84
|
+
|
85
|
+
assert !@record.errors.any?
|
86
|
+
end
|
87
|
+
|
88
|
+
should "be valid if no :self_transition_match is specified and nothing is allowed" do
|
89
|
+
validator = StateHistoryValidator.new(
|
90
|
+
:association => :state_history_entries
|
91
|
+
)
|
92
|
+
validator.validate(@record)
|
93
|
+
|
94
|
+
assert !@record.errors.any?
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
context "A \"perfect\" state history with a non-nil end" do
|
99
|
+
setup do
|
100
|
+
now = DateTime.new(2011, 2, 3, 5, 8, 13)
|
101
|
+
starts = [now, now + 2.months, now + 3.months, now + 5.months]
|
102
|
+
ends = [now + 2.months, now + 3.months, now + 5.months, now + 8.months]
|
103
|
+
|
104
|
+
@state_history = mock_state_history(starts, ends, nil) {}
|
105
|
+
|
106
|
+
@record = Validatable.new(@state_history)
|
107
|
+
end
|
108
|
+
|
109
|
+
should "not be valid with nothing allowed" do
|
110
|
+
validator = StateHistoryValidator.new(
|
111
|
+
:association => :state_history_entries,
|
112
|
+
:self_transition_match => [:id]
|
113
|
+
)
|
114
|
+
validator.validate(@record)
|
115
|
+
|
116
|
+
assert @record.errors.any?
|
117
|
+
end
|
118
|
+
|
119
|
+
should "be valid with :inactive_end_state allowed" do
|
120
|
+
validator = StateHistoryValidator.new(
|
121
|
+
:association => :state_history_entries,
|
122
|
+
:self_transition_match => [:id],
|
123
|
+
:allow => [:inactive_end_state]
|
124
|
+
)
|
125
|
+
validator.validate(@record)
|
126
|
+
|
127
|
+
assert !@record.errors.any?
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
context "A state history with overlaps" do
|
132
|
+
setup do
|
133
|
+
now = DateTime.new(2011, 2, 3, 5, 8, 13)
|
134
|
+
starts = [now, now + 2.months, now + 3.months, now + 5.months]
|
135
|
+
ends = [now + 2.months, now + 3.months, now + 6.months, nil]
|
136
|
+
|
137
|
+
@state_history = mock_state_history(starts, ends) {}
|
138
|
+
|
139
|
+
@record = Validatable.new(@state_history)
|
140
|
+
end
|
141
|
+
|
142
|
+
should "not be valid with nothing allowed" do
|
143
|
+
validator = StateHistoryValidator.new(
|
144
|
+
:association => :state_history_entries,
|
145
|
+
:self_transition_match => [:id]
|
146
|
+
)
|
147
|
+
validator.validate(@record)
|
148
|
+
|
149
|
+
assert @record.errors.any?
|
150
|
+
end
|
151
|
+
|
152
|
+
should "be valid with :overlaps allowed" do
|
153
|
+
validator = StateHistoryValidator.new(
|
154
|
+
:association => :state_history_entries,
|
155
|
+
:self_transition_match => [:id],
|
156
|
+
:allow => [:overlaps]
|
157
|
+
)
|
158
|
+
validator.validate(@record)
|
159
|
+
|
160
|
+
assert !@record.errors.any?
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
context "A state history with gaps" do
|
165
|
+
setup do
|
166
|
+
now = DateTime.new(2011, 2, 3, 5, 8, 13)
|
167
|
+
starts = [now, now + 2.months, now + 3.months, now + 5.months]
|
168
|
+
ends = [now + 2.months, now + 3.months, now + 4.months, nil]
|
169
|
+
|
170
|
+
state_history = mock_state_history(starts, ends) {}
|
171
|
+
@record = Validatable.new(state_history)
|
172
|
+
end
|
173
|
+
|
174
|
+
should "not be valid with nothing allowed" do
|
175
|
+
validator = StateHistoryValidator.new(
|
176
|
+
:association => :state_history_entries,
|
177
|
+
:self_transition_match => [:id]
|
178
|
+
)
|
179
|
+
validator.validate(@record)
|
180
|
+
|
181
|
+
assert @record.errors.any?
|
182
|
+
end
|
183
|
+
|
184
|
+
should "be valid with :gaps allowed" do
|
185
|
+
validator = StateHistoryValidator.new(
|
186
|
+
:association => :state_history_entries,
|
187
|
+
:self_transition_match => [:id],
|
188
|
+
:allow => [:gaps]
|
189
|
+
)
|
190
|
+
validator.validate(@record)
|
191
|
+
|
192
|
+
assert !@record.errors.any?
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
context "A state history with nil `:ends` in the middle" do
|
197
|
+
setup do
|
198
|
+
now = DateTime.new(2011, 2, 3, 5, 8, 13)
|
199
|
+
@starts = [now, now + 2.months, now + 3.months, now + 5.months]
|
200
|
+
@ends = [now + 2.months, nil, now + 5.months, nil]
|
201
|
+
|
202
|
+
state_history = mock_state_history(@starts, @ends) {}
|
203
|
+
@record = Validatable.new(state_history)
|
204
|
+
end
|
205
|
+
|
206
|
+
should "not be valid with nothing allowed" do
|
207
|
+
validator = StateHistoryValidator.new(
|
208
|
+
:association => :state_history_entries,
|
209
|
+
:self_transition_match => [:id]
|
210
|
+
)
|
211
|
+
validator.validate(@record)
|
212
|
+
|
213
|
+
assert @record.errors.any?
|
214
|
+
end
|
215
|
+
|
216
|
+
should "be valid with :active_middle_states allowed" do
|
217
|
+
validator = StateHistoryValidator.new(
|
218
|
+
:association => :state_history_entries,
|
219
|
+
:self_transition_match => [:id],
|
220
|
+
:allow => [:active_middle_states]
|
221
|
+
)
|
222
|
+
validator.validate(@record)
|
223
|
+
|
224
|
+
assert !@record.errors.any?
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
context "A state history with two consecutive equivalent states separated by a gap" do
|
229
|
+
setup do
|
230
|
+
now = DateTime.new(2011, 2, 3, 5, 8, 13)
|
231
|
+
@starts = [now, now + 2.months]
|
232
|
+
@ends = [now + 1.months, nil]
|
233
|
+
@id = [0, 0]
|
234
|
+
|
235
|
+
state_history = mock_state_history(@starts, @ends, @id) {}
|
236
|
+
|
237
|
+
@record = Validatable.new(state_history)
|
238
|
+
end
|
239
|
+
|
240
|
+
should "not be valid with nothing allowed" do
|
241
|
+
validator = StateHistoryValidator.new(
|
242
|
+
:association => :state_history_entries,
|
243
|
+
:self_transition_match => [:id]
|
244
|
+
)
|
245
|
+
validator.validate(@record)
|
246
|
+
|
247
|
+
assert @record.errors.any?
|
248
|
+
end
|
249
|
+
|
250
|
+
should "be valid with :gaps allowed" do
|
251
|
+
validator = StateHistoryValidator.new(
|
252
|
+
:association => :state_history_entries,
|
253
|
+
:self_transition_match => [:id],
|
254
|
+
:allow => [:gaps]
|
255
|
+
)
|
256
|
+
validator.validate(@record)
|
257
|
+
|
258
|
+
assert !@record.errors.any?
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
private
|
263
|
+
|
264
|
+
class Validatable < Struct.new(:state_history_entries)
|
265
|
+
include ActiveModel::Validations
|
266
|
+
end
|
267
|
+
|
268
|
+
class ValidatableEntry < Struct.new(:start, :end, :id, :other_id)
|
269
|
+
end
|
270
|
+
|
271
|
+
# fake the entries.order(@order).all call
|
272
|
+
def state_history_stub(state_history, options = {})
|
273
|
+
start_attr = options[:start] || :start
|
274
|
+
end_attr = options[:end] || :end
|
275
|
+
sort_dir = options[:sort_dir] || "ASC"
|
276
|
+
|
277
|
+
order_by = options[:order] ||
|
278
|
+
"#{start_attr} #{sort_dir}, ISNULL(#{end_attr}) #{sort_dir}, #{end_attr} #{sort_dir}"
|
279
|
+
|
280
|
+
state_history.stubs(:order).with(order_by).returns(state_history)
|
281
|
+
state_history.stubs(:all).returns(state_history)
|
282
|
+
end
|
283
|
+
|
284
|
+
# Set up a state_history with fake objects and then yield to allow further customization
|
285
|
+
def mock_state_history(starts, ends, ids = nil, options = {})
|
286
|
+
stub_history = (options[:stub_history].nil?) ? true : options[:stub_history]
|
287
|
+
entry_class = (options[:entry_class].nil?) ? ValidatableEntry : options[:entry_class]
|
288
|
+
|
289
|
+
state_history = []
|
290
|
+
starts.each_with_index do |start, i|
|
291
|
+
_end = ends[i]
|
292
|
+
id = (ids.nil?) ? i : ids[i]
|
293
|
+
entry = entry_class.new(start, _end, id)
|
294
|
+
|
295
|
+
yield entry, i
|
296
|
+
|
297
|
+
state_history << entry
|
298
|
+
end
|
299
|
+
|
300
|
+
state_history_stub(state_history, options) if stub_history
|
301
|
+
|
302
|
+
state_history
|
303
|
+
end
|
304
|
+
|
305
|
+
end
|
306
|
+
|
307
|
+
end
|
308
|
+
end
|
metadata
ADDED
@@ -0,0 +1,171 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: state_validations
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 23
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 2
|
9
|
+
- 0
|
10
|
+
version: 0.2.0
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Daniel Ge
|
14
|
+
- PagerDuty, Inc.
|
15
|
+
autorequire:
|
16
|
+
bindir: bin
|
17
|
+
cert_chain: []
|
18
|
+
|
19
|
+
date: 2012-07-18 00:00:00 Z
|
20
|
+
dependencies:
|
21
|
+
- !ruby/object:Gem::Dependency
|
22
|
+
version_requirements: &id001 !ruby/object:Gem::Requirement
|
23
|
+
none: false
|
24
|
+
requirements:
|
25
|
+
- - ">="
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
hash: 7
|
28
|
+
segments:
|
29
|
+
- 3
|
30
|
+
- 0
|
31
|
+
- 0
|
32
|
+
version: 3.0.0
|
33
|
+
prerelease: false
|
34
|
+
type: :runtime
|
35
|
+
name: activemodel
|
36
|
+
requirement: *id001
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
version_requirements: &id002 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
hash: 7
|
44
|
+
segments:
|
45
|
+
- 3
|
46
|
+
- 0
|
47
|
+
- 0
|
48
|
+
version: 3.0.0
|
49
|
+
prerelease: false
|
50
|
+
type: :runtime
|
51
|
+
name: activesupport
|
52
|
+
requirement: *id002
|
53
|
+
- !ruby/object:Gem::Dependency
|
54
|
+
version_requirements: &id003 !ruby/object:Gem::Requirement
|
55
|
+
none: false
|
56
|
+
requirements:
|
57
|
+
- - ">="
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
hash: 3
|
60
|
+
segments:
|
61
|
+
- 0
|
62
|
+
version: "0"
|
63
|
+
prerelease: false
|
64
|
+
type: :development
|
65
|
+
name: rake
|
66
|
+
requirement: *id003
|
67
|
+
- !ruby/object:Gem::Dependency
|
68
|
+
version_requirements: &id004 !ruby/object:Gem::Requirement
|
69
|
+
none: false
|
70
|
+
requirements:
|
71
|
+
- - "="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
hash: 7
|
74
|
+
segments:
|
75
|
+
- 2
|
76
|
+
- 2
|
77
|
+
version: "2.2"
|
78
|
+
prerelease: false
|
79
|
+
type: :development
|
80
|
+
name: test-unit
|
81
|
+
requirement: *id004
|
82
|
+
- !ruby/object:Gem::Dependency
|
83
|
+
version_requirements: &id005 !ruby/object:Gem::Requirement
|
84
|
+
none: false
|
85
|
+
requirements:
|
86
|
+
- - ">="
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
hash: 3
|
89
|
+
segments:
|
90
|
+
- 0
|
91
|
+
version: "0"
|
92
|
+
prerelease: false
|
93
|
+
type: :development
|
94
|
+
name: mocha
|
95
|
+
requirement: *id005
|
96
|
+
- !ruby/object:Gem::Dependency
|
97
|
+
version_requirements: &id006 !ruby/object:Gem::Requirement
|
98
|
+
none: false
|
99
|
+
requirements:
|
100
|
+
- - ">="
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
hash: 3
|
103
|
+
segments:
|
104
|
+
- 0
|
105
|
+
version: "0"
|
106
|
+
prerelease: false
|
107
|
+
type: :development
|
108
|
+
name: shoulda
|
109
|
+
requirement: *id006
|
110
|
+
description: A gem that verifies the integrity of a state history
|
111
|
+
email:
|
112
|
+
- daniel@pagerduty.com
|
113
|
+
executables: []
|
114
|
+
|
115
|
+
extensions: []
|
116
|
+
|
117
|
+
extra_rdoc_files: []
|
118
|
+
|
119
|
+
files:
|
120
|
+
- .gitignore
|
121
|
+
- Gemfile
|
122
|
+
- LICENSE
|
123
|
+
- README.md
|
124
|
+
- Rakefile
|
125
|
+
- lib/active_model/validations/helper_methods.rb
|
126
|
+
- lib/active_model/validations/state_history_entry_validator.rb
|
127
|
+
- lib/active_model/validations/state_history_validator.rb
|
128
|
+
- lib/state_validations.rb
|
129
|
+
- state_validations.gemspec
|
130
|
+
- test/test_helper.rb
|
131
|
+
- test/unit/helper_methods_test.rb
|
132
|
+
- test/unit/state_history_entry_validator_test.rb
|
133
|
+
- test/unit/state_history_validator_test.rb
|
134
|
+
homepage: https://github.com/PagerDuty/state-history-validator
|
135
|
+
licenses: []
|
136
|
+
|
137
|
+
post_install_message:
|
138
|
+
rdoc_options: []
|
139
|
+
|
140
|
+
require_paths:
|
141
|
+
- lib
|
142
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
143
|
+
none: false
|
144
|
+
requirements:
|
145
|
+
- - ">="
|
146
|
+
- !ruby/object:Gem::Version
|
147
|
+
hash: 3
|
148
|
+
segments:
|
149
|
+
- 0
|
150
|
+
version: "0"
|
151
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
152
|
+
none: false
|
153
|
+
requirements:
|
154
|
+
- - ">="
|
155
|
+
- !ruby/object:Gem::Version
|
156
|
+
hash: 3
|
157
|
+
segments:
|
158
|
+
- 0
|
159
|
+
version: "0"
|
160
|
+
requirements: []
|
161
|
+
|
162
|
+
rubyforge_project:
|
163
|
+
rubygems_version: 1.8.24
|
164
|
+
signing_key:
|
165
|
+
specification_version: 3
|
166
|
+
summary: State History and State History Entry Validators
|
167
|
+
test_files:
|
168
|
+
- test/test_helper.rb
|
169
|
+
- test/unit/helper_methods_test.rb
|
170
|
+
- test/unit/state_history_entry_validator_test.rb
|
171
|
+
- test/unit/state_history_validator_test.rb
|