state_validations 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,20 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+
19
+ *.swp
20
+ .rvmrc
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
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.
@@ -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
@@ -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,6 @@
1
+ require 'active_model/validations/state_history_validator'
2
+ require 'active_model/validations/state_history_entry_validator'
3
+ require 'active_model/validations/helper_methods'
4
+
5
+ module StateValidations
6
+ end
@@ -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
@@ -0,0 +1,8 @@
1
+ require 'test/unit'
2
+ require 'shoulda'
3
+ require 'mocha'
4
+ require 'mocha/integration/test_unit'
5
+ require 'active_support/core_ext'
6
+ require 'active_model'
7
+
8
+ require 'state_validations'
@@ -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