check_please 0.4.1 → 0.5.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.
@@ -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