check_please 0.4.1 → 0.5.4

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.
@@ -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