check_please 0.4.1 → 0.5.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -13,7 +13,7 @@ module CheckPlease
13
13
  catch(:max_diffs_reached) do
14
14
  compare reference, candidate, CheckPlease::Path.root
15
15
  end
16
- diffs
16
+ diffs.filter_by_flags(@flags)
17
17
  end
18
18
 
19
19
  private
@@ -22,7 +22,7 @@ module CheckPlease
22
22
  def compare(ref, can, path)
23
23
  return if path.excluded?(flags)
24
24
 
25
- case types(ref, can)
25
+ case types_for_compare(ref, can)
26
26
  when [ :array, :array ] ; compare_arrays ref, can, path
27
27
  when [ :hash, :hash ] ; compare_hashes ref, can, path
28
28
  when [ :other, :other ] ; compare_others ref, can, path
@@ -31,7 +31,7 @@ module CheckPlease
31
31
  end
32
32
  end
33
33
 
34
- def types(*list)
34
+ def types_for_compare(*list)
35
35
  list.map { |e|
36
36
  case e
37
37
  when Array ; :array
@@ -42,29 +42,153 @@ module CheckPlease
42
42
  end
43
43
 
44
44
  def compare_arrays(ref_array, can_array, path)
45
- max_len = [ ref_array, can_array ].map(&:length).max
46
- (0...max_len).each do |i|
47
- n = i + 1 # count in human pls
48
- new_path = path + n
49
-
50
- ref = ref_array[i]
51
- can = can_array[i]
52
-
53
- case
54
- when ref_array.length < n ; record_diff ref, can, new_path, :extra
55
- when can_array.length < n ; record_diff ref, can, new_path, :missing
56
- else
57
- compare ref, can, new_path
58
- end
45
+ case
46
+ when ( key = path.key_to_match_by(flags) )
47
+ compare_arrays_by_key ref_array, can_array, path, key
48
+ when path.match_by_value?(flags)
49
+ compare_arrays_by_value ref_array, can_array, path
50
+ else
51
+ compare_arrays_by_index ref_array, can_array, path
59
52
  end
60
53
  end
61
54
 
55
+ def compare_arrays_by_key(ref_array, can_array, path, key_name)
56
+ refs_by_key = index_array!(ref_array, path, key_name, "reference")
57
+ cans_by_key = index_array!(can_array, path, key_name, "candidate")
58
+
59
+ key_values = (refs_by_key.keys | cans_by_key.keys)
60
+
61
+ key_values.compact! # NOTE: will break if nil is ever used as a key (but WHO WOULD DO THAT?!)
62
+ key_values.sort!
63
+
64
+ key_values.each do |key_value|
65
+ new_path = path + "#{key_name}=#{key_value}"
66
+ ref = refs_by_key[key_value]
67
+ can = cans_by_key[key_value]
68
+ case
69
+ when ref.nil? ; record_diff ref, can, new_path, :extra
70
+ when can.nil? ; record_diff ref, can, new_path, :missing
71
+ else ; compare ref, can, new_path
72
+ end
73
+ end
74
+ end
75
+
76
+ def index_array!(array_of_hashes, path, key_name, ref_or_can)
77
+ elements_by_key = {}
78
+
79
+ array_of_hashes.each.with_index do |h, i|
80
+ # make sure we have a hash
81
+ unless h.is_a?(Hash)
82
+ raise CheckPlease::TypeMismatchError, \
83
+ "The element at position #{i} in the #{ref_or_can} array is not a hash."
84
+ end
85
+
86
+ if flags.indifferent_keys
87
+ h = stringify_symbol_keys(h)
88
+ end
89
+
90
+ # try to get the value of the attribute identified by key_name
91
+ key_value = h.fetch(key_name) {
92
+ raise CheckPlease::NoSuchKeyError, \
93
+ <<~EOF
94
+ The #{ref_or_can} hash at position #{i} has no #{key_name.inspect} key.
95
+ Keys it does have: #{h.keys.inspect}
96
+ EOF
97
+ }
98
+
99
+ # complain about dupes
100
+ if elements_by_key.has_key?(key_value)
101
+ key_val_expr = "#{key_name}=#{key_value}"
102
+ raise CheckPlease::DuplicateKeyError, \
103
+ "Duplicate #{ref_or_can} element found at path '#{path + key_val_expr}'."
104
+ end
105
+
106
+ # ok, now we can proceed
107
+ elements_by_key[key_value] = h
108
+ end
109
+
110
+ elements_by_key
111
+ end
112
+
113
+ # FIXME: this can generate duplicate paths.
114
+ # Time to introduce lft_path, rgt_path ?
115
+ def compare_arrays_by_value(ref_array, can_array, path)
116
+ assert_can_match_by_value! ref_array
117
+ assert_can_match_by_value! can_array
118
+
119
+ matches = can_array.map { false }
120
+
121
+ # Look for missing values
122
+ ref_array.each.with_index do |ref, i|
123
+ new_path = path + (i+1) # count in human pls
124
+
125
+ # Weird, but necessary to handle duplicates properly
126
+ j = can_array.index.with_index { |can, j|
127
+ matches[j] == false && can == ref
128
+ }
129
+
130
+ if j
131
+ matches[j] = true
132
+ else
133
+ record_diff ref, nil, new_path, :missing
134
+ end
135
+ end
136
+
137
+ # Look for extra values
138
+ can_array.zip(matches).each.with_index do |(can, match), i|
139
+ next if match
140
+ new_path = path + (i+1) # count in human pls
141
+ record_diff nil, can, new_path, :extra
142
+ end
143
+ end
144
+
145
+ def assert_can_match_by_value!(array)
146
+ if array.any? { |e| Array === e || Hash === e }
147
+ raise CheckPlease::BehaviorUndefined,
148
+ "match_by_value behavior is not defined for collections!"
149
+ end
150
+ end
151
+
152
+ def compare_arrays_by_index(ref_array, can_array, path)
153
+ max_len = [ ref_array, can_array ].map(&:length).max
154
+ (0...max_len).each do |i|
155
+ n = i + 1 # count in human pls
156
+ new_path = path + n
157
+
158
+ ref = ref_array[i]
159
+ can = can_array[i]
160
+
161
+ case
162
+ when ref_array.length < n ; record_diff ref, can, new_path, :extra
163
+ when can_array.length < n ; record_diff ref, can, new_path, :missing
164
+ else
165
+ compare ref, can, new_path
166
+ end
167
+ end
168
+ end
169
+
62
170
  def compare_hashes(ref_hash, can_hash, path)
171
+ if flags.indifferent_keys
172
+ ref_hash = stringify_symbol_keys(ref_hash)
173
+ can_hash = stringify_symbol_keys(can_hash)
174
+ end
63
175
  record_missing_keys ref_hash, can_hash, path
64
176
  compare_common_keys ref_hash, can_hash, path
65
177
  record_extra_keys ref_hash, can_hash, path
66
178
  end
67
179
 
180
+ def stringify_symbol_keys(h)
181
+ Hash[
182
+ h.map { |k,v|
183
+ [ stringify_symbol(k), v ]
184
+ }
185
+ ]
186
+ end
187
+
188
+ def stringify_symbol(x)
189
+ Symbol === x ? x.to_s : x
190
+ end
191
+
68
192
  def record_missing_keys(ref_hash, can_hash, path)
69
193
  keys = ref_hash.keys - can_hash.keys
70
194
  keys.each do |k|
@@ -87,6 +211,10 @@ module CheckPlease
87
211
  end
88
212
 
89
213
  def compare_others(ref, can, path)
214
+ if flags.indifferent_values
215
+ ref = stringify_symbol(ref)
216
+ can = stringify_symbol(can)
217
+ end
90
218
  return if ref == can
91
219
  record_diff ref, can, path, :mismatch
92
220
  end
@@ -47,6 +47,15 @@ module CheckPlease
47
47
  @list.map(&:attributes)
48
48
  end
49
49
 
50
+ def filter_by_flags(flags)
51
+ new_list = @list.reject { |diff| Path.new(diff.path).excluded?(flags) }
52
+ self.class.new(new_list, flags: flags)
53
+ end
54
+
55
+ def to_s(flags = {})
56
+ CheckPlease::Printers.render(self, flags)
57
+ end
58
+
50
59
  extend Forwardable
51
60
  def_delegators :@list, *%i[
52
61
  each
@@ -6,8 +6,32 @@ module CheckPlease
6
6
  # instead....
7
7
  end
8
8
 
9
+ class BehaviorUndefined < ::StandardError
10
+ include CheckPlease::Error
11
+ end
12
+
13
+ class DuplicateKeyError < ::IndexError
14
+ include CheckPlease::Error
15
+ end
16
+
9
17
  class InvalidFlag < ArgumentError
10
18
  include CheckPlease::Error
11
19
  end
12
20
 
21
+ class InvalidPath < ArgumentError
22
+ include CheckPlease::Error
23
+ end
24
+
25
+ class InvalidPathSegment < ArgumentError
26
+ include CheckPlease::Error
27
+ end
28
+
29
+ class NoSuchKeyError < ::KeyError
30
+ include CheckPlease::Error
31
+ end
32
+
33
+ class TypeMismatchError < ::TypeError
34
+ include CheckPlease::Error
35
+ end
36
+
13
37
  end
@@ -29,12 +29,22 @@ module CheckPlease
29
29
  @coercer = block
30
30
  end
31
31
 
32
+ def description=(value)
33
+ if value.is_a?(String) && value =~ /\n/m
34
+ lines = value.lines
35
+ else
36
+ lines = Array(value).map(&:to_s)
37
+ end
38
+
39
+ @description = lines.map(&:rstrip)
40
+ end
41
+
32
42
  def mutually_exclusive_to(flag_name)
33
43
  @validators << ->(flags, _) { flags.send(flag_name).empty? }
34
44
  end
35
45
 
36
- def reentrant
37
- @reentrant = true
46
+ def repeatable
47
+ @repeatable = true
38
48
  self.default_proc = ->{ Array.new }
39
49
  end
40
50
 
@@ -47,7 +57,7 @@ module CheckPlease
47
57
  def __set__(value, on:, flags:)
48
58
  val = _coerce(value)
49
59
  _validate(flags, val)
50
- if @reentrant
60
+ if @repeatable
51
61
  on[name] ||= []
52
62
  on[name].concat(Array(val))
53
63
  else
@@ -1,29 +1,75 @@
1
1
  module CheckPlease
2
2
 
3
+ # TODO: this class is getting a bit large; maybe split out some of the stuff that uses flags?
3
4
  class Path
5
+ include CheckPlease::Reification
6
+ can_reify String, Symbol, Numeric, nil
7
+
4
8
  SEPARATOR = "/"
5
9
 
6
10
  def self.root
7
- new
11
+ new('/')
8
12
  end
9
13
 
10
- attr_reader :to_s
11
- def initialize(segments = [])
14
+
15
+
16
+ attr_reader :to_s, :segments
17
+ def initialize(name_or_segments = [])
18
+ case name_or_segments
19
+ when String, Symbol, Numeric, nil
20
+ string = name_or_segments.to_s
21
+ if string =~ %r(//)
22
+ raise InvalidPath, "paths cannot have empty segments"
23
+ end
24
+
25
+ names = string.split(SEPARATOR)
26
+ names.shift until names.empty? || names.first =~ /\S/
27
+ segments = PathSegment.reify(names)
28
+ when Array
29
+ segments = PathSegment.reify(name_or_segments)
30
+ else
31
+ raise InvalidPath, "not sure what to do with #{name_or_segments.inspect}"
32
+ end
33
+
12
34
  @segments = Array(segments)
35
+
13
36
  @to_s = SEPARATOR + @segments.join(SEPARATOR)
14
37
  freeze
38
+ rescue InvalidPathSegment => e
39
+ raise InvalidPath, e.message
15
40
  end
16
41
 
17
42
  def +(new_basename)
18
- self.class.new( Array(@segments) + Array(new_basename.to_s) )
43
+ new_segments = self.segments.dup
44
+ new_segments << new_basename # don't reify here; it'll get done on Path#initialize
45
+ self.class.new(new_segments)
46
+ end
47
+
48
+ def ==(other)
49
+ self.to_s == other.to_s
50
+ end
51
+
52
+ def ancestors
53
+ list = []
54
+ p = self
55
+ loop do
56
+ break if p.root?
57
+ p = p.parent
58
+ list.unshift p
59
+ end
60
+ list.reverse
61
+ end
62
+
63
+ def basename
64
+ segments.last.to_s
19
65
  end
20
66
 
21
67
  def depth
22
- 1 + @segments.length
68
+ 1 + segments.length
23
69
  end
24
70
 
25
71
  def excluded?(flags)
26
- return false if root?
72
+ return false if root? # that would just be silly
27
73
 
28
74
  return true if too_deep?(flags)
29
75
  return true if explicitly_excluded?(flags)
@@ -33,33 +79,96 @@ module CheckPlease
33
79
  end
34
80
 
35
81
  def inspect
36
- "<CheckPlease::Path '#{to_s}'>"
82
+ "<#{self.class.name} '#{to_s}'>"
83
+ end
84
+
85
+ def key_to_match_by(flags)
86
+ key_exprs = unpack_key_exprs(flags.match_by_key)
87
+ # NOTE: match on parent because if self.to_s == '/foo', MBK '/foo/:id' should return 'id'
88
+ matches = key_exprs.select { |e| e.parent.match?(self) }
89
+
90
+ case matches.length
91
+ when 0 ; nil
92
+ when 1 ; matches.first.segments.last.key
93
+ else ; raise "More than one match_by_key expression for path '#{self}': #{matches.map(&:to_s).inspect}"
94
+ end
95
+ end
96
+
97
+ def match_by_value?(flags)
98
+ flags.match_by_value.any? { |e| e.match?(self) }
99
+ end
100
+
101
+ def match?(path_or_string)
102
+ # If the strings are literally equal, we're good..
103
+ return true if self == path_or_string
104
+
105
+ # Otherwise, compare segments: do we have the same number, and do they all #match?
106
+ other = reify(path_or_string)
107
+ return false if other.depth != self.depth
108
+
109
+ seg_pairs = self.segments.zip(other.segments)
110
+ seg_pairs.all? { |a, b| a.match?(b) }
111
+ end
112
+
113
+ def parent
114
+ return nil if root? # TODO: consider the Null Object pattern
115
+ self.class.new(segments[0..-2])
37
116
  end
38
117
 
39
118
  def root?
40
- to_s == SEPARATOR
119
+ @segments.empty?
41
120
  end
42
121
 
43
122
  private
44
123
 
124
+ # O(n^2) check to see if any of the path's ancestors are on a list
125
+ # (as of this writing, this should never actually happen, but I'm being thorough)
126
+ def ancestor_on_list?(paths)
127
+ paths.any? { |path|
128
+ ancestors.any? { |ancestor| ancestor.match?(path) }
129
+ }
130
+ end
131
+
45
132
  def explicitly_excluded?(flags)
46
- flags.reject_paths.any?( &method(:match?) )
133
+ return false if flags.reject_paths.empty?
134
+ return true if self_on_list?(flags.reject_paths)
135
+ return true if ancestor_on_list?(flags.reject_paths)
136
+ false
47
137
  end
48
138
 
49
139
  def implicitly_excluded?(flags)
50
140
  return false if flags.select_paths.empty?
51
- flags.select_paths.none?( &method(:match?) )
141
+ return false if self_on_list?(flags.select_paths)
142
+ return false if ancestor_on_list?(flags.select_paths)
143
+ true
144
+ end
145
+
146
+ # A path of "/foo/:id/bar/:name" has two key expressions:
147
+ # - "/foo/:id"
148
+ # - "/foo/:id/bar/:name"
149
+ def key_exprs
150
+ ( [self] + ancestors )
151
+ .reject { |path| path.root? }
152
+ .select { |path| path.segments.last&.key_expr? }
52
153
  end
53
154
 
54
- # leaving this here for a while in case it needs to grow into a public method
55
- def match?(path_expr)
56
- to_s.include?(path_expr)
155
+ # O(n) check to see if the path itself is on a list
156
+ def self_on_list?(paths)
157
+ paths.any? { |path| self.match?(path) }
57
158
  end
58
159
 
59
160
  def too_deep?(flags)
60
161
  return false if flags.max_depth.nil?
61
- flags.max_depth + 1 < depth
162
+ depth > flags.max_depth
62
163
  end
164
+
165
+ def unpack_key_exprs(path_list)
166
+ path_list
167
+ .map { |path| path.send(:key_exprs) }
168
+ .flatten
169
+ .uniq { |e| e.to_s } # use the block form so we don't have to implement #hash and #eql? in horrible ways
170
+ end
171
+
63
172
  end
64
173
 
65
174
  end