check_please 0.4.1 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -42,23 +42,84 @@ 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
+ if ( key = path.key_for_compare(flags) )
46
+ compare_arrays_by_key ref_array, can_array, path, key
47
+ else
48
+ compare_arrays_by_index ref_array, can_array, path
59
49
  end
60
50
  end
61
51
 
52
+ def compare_arrays_by_key(ref_array, can_array, path, key_name)
53
+ refs_by_key = index_array!(ref_array, path, key_name, "reference")
54
+ cans_by_key = index_array!(can_array, path, key_name, "candidate")
55
+ key_values = (refs_by_key.keys | cans_by_key.keys)
56
+
57
+ key_values.compact! # NOTE: will break if nil is ever used as a key (but WHO WOULD DO THAT?!)
58
+ key_values.sort!
59
+
60
+ key_values.each do |key_value|
61
+ new_path = path + "#{key_name}=#{key_value}"
62
+ ref = refs_by_key[key_value]
63
+ can = cans_by_key[key_value]
64
+ case
65
+ when ref.nil? ; record_diff ref, can, new_path, :extra
66
+ when can.nil? ; record_diff ref, can, new_path, :missing
67
+ else ; compare ref, can, new_path
68
+ end
69
+ end
70
+ end
71
+
72
+ def index_array!(array_of_hashes, path, key_name, ref_or_can)
73
+ elements_by_key = {}
74
+
75
+ array_of_hashes.each.with_index do |h, i|
76
+ # make sure we have a hash
77
+ unless h.is_a?(Hash)
78
+ raise CheckPlease::TypeMismatchError, \
79
+ "The element at position #{i} in the #{ref_or_can} array is not a hash."
80
+ end
81
+
82
+ # try to get the value of the attribute identified by key_name
83
+ key_value = h.fetch(key_name) {
84
+ raise CheckPlease::NoSuchKeyError, \
85
+ <<~EOF
86
+ The #{ref_or_can} hash at position #{i} has no #{key_name.inspect} key.
87
+ Keys it does have: #{h.keys.inspect}
88
+ EOF
89
+ }
90
+
91
+ # complain about dupes
92
+ if elements_by_key.has_key?(key_value)
93
+ key_val_expr = "#{key_name}=#{key_value}"
94
+ raise CheckPlease::DuplicateKeyError, \
95
+ "Duplicate #{ref_or_can} element found at path '#{path + key_val_expr}'."
96
+ end
97
+
98
+ # ok, now we can proceed
99
+ elements_by_key[key_value] = h
100
+ end
101
+
102
+ elements_by_key
103
+ end
104
+
105
+ def compare_arrays_by_index(ref_array, can_array, path)
106
+ max_len = [ ref_array, can_array ].map(&:length).max
107
+ (0...max_len).each do |i|
108
+ n = i + 1 # count in human pls
109
+ new_path = path + n
110
+
111
+ ref = ref_array[i]
112
+ can = can_array[i]
113
+
114
+ case
115
+ when ref_array.length < n ; record_diff ref, can, new_path, :extra
116
+ when can_array.length < n ; record_diff ref, can, new_path, :missing
117
+ else
118
+ compare ref, can, new_path
119
+ end
120
+ end
121
+ end
122
+
62
123
  def compare_hashes(ref_hash, can_hash, path)
63
124
  record_missing_keys ref_hash, can_hash, path
64
125
  compare_common_keys ref_hash, can_hash, path
@@ -47,6 +47,10 @@ module CheckPlease
47
47
  @list.map(&:attributes)
48
48
  end
49
49
 
50
+ def to_s(flags = {})
51
+ CheckPlease::Printers.render(self, flags)
52
+ end
53
+
50
54
  extend Forwardable
51
55
  def_delegators :@list, *%i[
52
56
  each
@@ -6,8 +6,28 @@ module CheckPlease
6
6
  # instead....
7
7
  end
8
8
 
9
+ class DuplicateKeyError < ::IndexError
10
+ include CheckPlease::Error
11
+ end
12
+
9
13
  class InvalidFlag < ArgumentError
10
14
  include CheckPlease::Error
11
15
  end
12
16
 
17
+ class InvalidPath < ArgumentError
18
+ include CheckPlease::Error
19
+ end
20
+
21
+ class InvalidPathSegment < ArgumentError
22
+ include CheckPlease::Error
23
+ end
24
+
25
+ class NoSuchKeyError < ::KeyError
26
+ include CheckPlease::Error
27
+ end
28
+
29
+ class TypeMismatchError < ::TypeError
30
+ include CheckPlease::Error
31
+ end
32
+
13
33
  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,74 @@
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
+ names = name_or_segments.to_s.split(SEPARATOR)
21
+ names.shift until names.empty? || names.first =~ /\S/
22
+ segments = PathSegment.reify(names)
23
+ when Array
24
+ segments = PathSegment.reify(name_or_segments)
25
+ else
26
+ raise InvalidPath, "not sure what to do with #{name_or_segments.inspect}"
27
+ end
28
+
29
+ if segments.any?(&:empty?)
30
+ raise InvalidPath, "#{self.class.name} cannot contain empty segments"
31
+ end
32
+
12
33
  @segments = Array(segments)
34
+
13
35
  @to_s = SEPARATOR + @segments.join(SEPARATOR)
14
36
  freeze
37
+ rescue InvalidPathSegment => e
38
+ raise InvalidPath, e.message
15
39
  end
16
40
 
17
41
  def +(new_basename)
18
- self.class.new( Array(@segments) + Array(new_basename.to_s) )
42
+ new_segments = self.segments.dup
43
+ new_segments << new_basename # don't reify here; it'll get done on Path#initialize
44
+ self.class.new(new_segments)
45
+ end
46
+
47
+ def ==(other)
48
+ self.to_s == other.to_s
49
+ end
50
+
51
+ def ancestors
52
+ list = []
53
+ p = self
54
+ loop do
55
+ break if p.root?
56
+ p = p.parent
57
+ list.unshift p
58
+ end
59
+ list.reverse
60
+ end
61
+
62
+ def basename
63
+ segments.last.to_s
19
64
  end
20
65
 
21
66
  def depth
22
- 1 + @segments.length
67
+ 1 + segments.length
23
68
  end
24
69
 
25
70
  def excluded?(flags)
26
- return false if root?
71
+ return false if root? # that would just be silly
27
72
 
28
73
  return true if too_deep?(flags)
29
74
  return true if explicitly_excluded?(flags)
@@ -33,33 +78,95 @@ module CheckPlease
33
78
  end
34
79
 
35
80
  def inspect
36
- "<CheckPlease::Path '#{to_s}'>"
81
+ "<#{self.class.name} '#{to_s}'>"
82
+ end
83
+
84
+ # TODO: Naming Things
85
+ def key_for_compare(flags)
86
+ mbk_exprs = unpack_mbk_exprs(flags)
87
+ matches = mbk_exprs.select { |mbk_expr|
88
+ # NOTE: matching on parent because MBK '/foo/:id' should return 'id' for path '/foo'
89
+ mbk_expr.parent.match?(self)
90
+ }
91
+
92
+ case matches.length
93
+ when 0 ; nil
94
+ when 1 ; matches.first.segments.last.key
95
+ else ; raise "More than one match_by_key expression for path '#{self}': #{matches.map(&:to_s).inspect}"
96
+ end
97
+ end
98
+
99
+ def match?(path_or_string)
100
+ # If the strings are literally equal, we're good..
101
+ return true if self == path_or_string
102
+
103
+ # Otherwise, compare segments: do we have the same number, and do they all #match?
104
+ other = reify(path_or_string)
105
+ return false if other.depth != self.depth
106
+
107
+ seg_pairs = self.segments.zip(other.segments)
108
+ seg_pairs.all? { |a, b| a.match?(b) }
109
+ end
110
+
111
+ def parent
112
+ return nil if root? # TODO: consider the Null Object pattern
113
+ self.class.new(segments[0..-2])
37
114
  end
38
115
 
39
116
  def root?
40
- to_s == SEPARATOR
117
+ @segments.empty?
41
118
  end
42
119
 
43
120
  private
44
121
 
122
+ # O(n^2) check to see if any of the path's ancestors are on a list
123
+ # (as of this writing, this should never actually happen, but I'm being thorough)
124
+ def ancestor_on_list?(paths)
125
+ paths.any? { |path|
126
+ ancestors.any? { |ancestor| ancestor == path }
127
+ }
128
+ end
129
+
45
130
  def explicitly_excluded?(flags)
46
- flags.reject_paths.any?( &method(:match?) )
131
+ return false if flags.reject_paths.empty?
132
+ return true if self_on_list?(flags.reject_paths)
133
+ return true if ancestor_on_list?(flags.reject_paths)
134
+ false
47
135
  end
48
136
 
49
137
  def implicitly_excluded?(flags)
50
138
  return false if flags.select_paths.empty?
51
- flags.select_paths.none?( &method(:match?) )
139
+ return false if self_on_list?(flags.select_paths)
140
+ return false if ancestor_on_list?(flags.select_paths)
141
+ true
142
+ end
143
+
144
+ # A path of "/foo/:id/bar/:name" has two key expressions:
145
+ # - "/foo/:id"
146
+ # - "/foo/:id/bar/:name"
147
+ def key_exprs
148
+ ( [self] + ancestors )
149
+ .reject { |path| path.root? }
150
+ .select { |path| path.segments.last&.key_expr? }
52
151
  end
53
152
 
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)
153
+ # O(n) check to see if the path itself is on a list
154
+ def self_on_list?(paths)
155
+ paths.any? { |path| self == path }
57
156
  end
58
157
 
59
158
  def too_deep?(flags)
60
159
  return false if flags.max_depth.nil?
61
- flags.max_depth + 1 < depth
160
+ depth > flags.max_depth
62
161
  end
162
+
163
+ def unpack_mbk_exprs(flags)
164
+ flags.match_by_key
165
+ .map { |path| path.send(:key_exprs) }
166
+ .flatten
167
+ .uniq { |e| e.to_s } # use the block form so we don't have to implement #hash and #eql? in horrible ways
168
+ end
169
+
63
170
  end
64
171
 
65
172
  end
@@ -0,0 +1,88 @@
1
+ module CheckPlease
2
+
3
+ class PathSegment
4
+ include CheckPlease::Reification
5
+ can_reify String, Symbol, Numeric, nil
6
+
7
+ KEY_EXPR = %r{
8
+ ^
9
+ \: # a literal colon
10
+ ( # capture key
11
+ [^\:]+ # followed by one or more things that aren't colons
12
+ ) # end capture key
13
+ $
14
+ }x
15
+
16
+ KEY_VAL_EXPR = %r{
17
+ ^
18
+ ( # capture key
19
+ [^=]+ # stuff (just not an equal sign)
20
+ ) # end capture key
21
+ \= # an equal sign
22
+ ( # capture key value
23
+ [^=]+ # stuff (just not an equal sign)
24
+ ) # end capture key value
25
+ $
26
+ }x
27
+
28
+ attr_reader :name, :key, :key_value
29
+ alias_method :to_s, :name
30
+
31
+ def initialize(name = nil)
32
+ @name = name.to_s.strip
33
+ if @name =~ %r(\s) # has any whitespace
34
+ raise InvalidPathSegment, <<~EOF
35
+ #{name.inspect} is not a valid #{self.class} name
36
+ EOF
37
+ end
38
+ parse_key_and_value
39
+ freeze
40
+ end
41
+
42
+ def empty?
43
+ name.empty?
44
+ end
45
+
46
+ def key_expr?
47
+ name.match?(KEY_EXPR)
48
+ end
49
+
50
+ def key_val_expr?
51
+ name.match?(KEY_VAL_EXPR)
52
+ end
53
+
54
+ def match?(other_segment_or_string)
55
+ other = self.class.reify(other_segment_or_string)
56
+
57
+ match_types = [ self.match_type, other.match_type ]
58
+ case match_types
59
+ when [ :plain, :plain ] ; self.name == other.name
60
+ when [ :key, :key_value ] ; self.key == other.key
61
+ when [ :key_value, :key ] ; self.key == other.key
62
+ else ; false
63
+ end
64
+ end
65
+
66
+ protected
67
+
68
+ def match_type
69
+ return :key if key_expr?
70
+ return :key_value if key_val_expr?
71
+ :plain
72
+ end
73
+
74
+ private
75
+
76
+ def parse_key_and_value
77
+ case name
78
+ when KEY_EXPR
79
+ @key = $1
80
+ when KEY_VAL_EXPR
81
+ @key, @key_value = $1, $2
82
+ else
83
+ # :nothingtodohere:
84
+ end
85
+ end
86
+ end
87
+
88
+ end
@@ -0,0 +1,50 @@
1
+ module CheckPlease
2
+
3
+ module Reification
4
+ def self.included(receiver)
5
+ receiver.extend ClassMethods
6
+ receiver.send :include, InstanceMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ def reifiable
11
+ @_reifiable ||= []
12
+ end
13
+
14
+ def can_reify(*klasses)
15
+ klasses.flatten!
16
+
17
+ unless ( klasses - [nil] ).all? { |e| e.is_a?(Class) }
18
+ raise ArgumentError, "classes (or nil) only, please"
19
+ end
20
+
21
+ reifiable.concat klasses
22
+ reifiable.uniq!
23
+ nil
24
+ end
25
+
26
+ def reify(primitive_or_object)
27
+ case primitive_or_object
28
+ when self ; return primitive_or_object
29
+ when Array ; return primitive_or_object.map { |e| reify(e) }
30
+ when *reifiable ; return new(primitive_or_object)
31
+ end
32
+ # note early return ^^^
33
+
34
+ # that didn't work? complain!
35
+ acceptable = reifiable.map { |e| Class === e ? e.name : e.inspect }
36
+ raise ArgumentError, <<~EOF
37
+ #{self}.reify was given: #{primitive_or_object.inspect}
38
+ but only accepts: #{acceptable.join(", ")}
39
+ EOF
40
+ end
41
+ end
42
+
43
+ module InstanceMethods
44
+ def reify(x)
45
+ self.class.reify(x)
46
+ end
47
+ end
48
+ end
49
+
50
+ end