dux 0.3.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,7 +1,14 @@
1
1
  module Dux
2
+ # `Object#blank?` and `Object#present?` replacements for when
3
+ # working outside of ActiveSupport.
4
+ #
5
+ # @api private
2
6
  module Blankness
7
+ # A string containing only whitespace (as defined by unicode).
3
8
  WHITESPACE_ONLY = /\A[[:space:]]+\z/
4
9
 
10
+ private_constant :WHITESPACE_ONLY
11
+
5
12
  # Check if a provided object is semantically empty.
6
13
  #
7
14
  # @param [Object] value
@@ -32,7 +39,7 @@ module Dux
32
39
  def presentish?(value)
33
40
  !blankish?(value)
34
41
  end
35
-
36
- extend self
37
42
  end
43
+
44
+ extend Blankness
38
45
  end
@@ -0,0 +1,241 @@
1
+ module Dux
2
+ # Simplify the creation of comparable objects by specifying
3
+ # a list of attributes (with optional ordering) that
4
+ # determines how they should be compared, without having
5
+ # to define a spaceship (`<=>`) operator in the given class.
6
+ class Comparable < Module
7
+ # Valid orders for sorting
8
+ ORDERS = Dux.enum(:asc, :desc, aliases: { ascending: :asc, descending: :desc })
9
+
10
+ private_constant :ORDERS
11
+
12
+ # Checks if an attribute argument is a valid tuple
13
+ ATTRIBUTE_TUPLE = Dux.yard('(Symbol, Symbol)')
14
+
15
+ private_constant :ATTRIBUTE_TUPLE
16
+
17
+ # @param [<Symbol, (Symbol, Symbol)>] attributes
18
+ # @param [Boolean, String] type_guard
19
+ # @param [:asc, :desc, :ascending, :descending] sort_order
20
+ def initialize(*attributes, sort_order: :asc, type_guard: true, **options)
21
+ @default_sort_order = validate_order sort_order
22
+
23
+ @type_guard = validate_type_guard type_guard
24
+
25
+ @attributes = parse_attributes(attributes)
26
+
27
+ if @attributes.one?
28
+ @default_sort_order = @attributes.first.order
29
+ end
30
+
31
+ include ::Comparable
32
+
33
+ class_eval spaceship_method, __FILE__, __LINE__ + 1
34
+ end
35
+
36
+ # Display the attributes used to compare for clarity
37
+ # in class ancestor listings.
38
+ #
39
+ # @return [String]
40
+ def inspect
41
+ attribute_list = @attributes.map do |attribute|
42
+ attribute.to_inspect(many: many?, default: @default_sort_order)
43
+ end.join(', ')
44
+
45
+ if many?
46
+ attribute_list = "[#{attribute_list}]"
47
+
48
+ attribute_list << ", default_order: #{@default_sort_order.to_s.upcase}"
49
+ end
50
+
51
+ "Dux::Comparable(#{attribute_list})"
52
+ end
53
+
54
+ # Determine if we are operating on many attributes
55
+ #
56
+ # Boolean complement of {#single?}.
57
+ def many?
58
+ @attributes.length > 1
59
+ end
60
+
61
+ # Determine if we are operating on a single attribute
62
+ #
63
+ # Boolean complement of {#many?}.
64
+ def single?
65
+ @attributes.one?
66
+ end
67
+
68
+ # Determine if we have a type guard that requires
69
+ # another of the same class.
70
+ def same_type_guard?
71
+ @type_guard == true
72
+ end
73
+
74
+ # Determine if
75
+ def specific_type_guard?
76
+ @type_guard.kind_of?(String) && Dux.presentish?(@type_guard)
77
+ end
78
+
79
+ # Determine if we have any kind of type guard.
80
+ #
81
+ # @see [#same_type_guard?]
82
+ # @see [#specific_type_guard?]
83
+ def type_guard?
84
+ same_type_guard? || specific_type_guard?
85
+ end
86
+
87
+ # @api private
88
+ # @return [String]
89
+ def spaceship_method
90
+ @spaceship_method ||= build_spaceship_method
91
+ end
92
+
93
+ private
94
+
95
+ # Generates the spaceship method body.
96
+ #
97
+ # @return [String]
98
+ def build_spaceship_method
99
+ ''.tap do |body|
100
+
101
+ body << <<-RUBY
102
+ def <=>(other)
103
+ RUBY
104
+
105
+ if type_guard?
106
+ body << <<-RUBY
107
+ unless other.kind_of?(#{type_guard_value})
108
+ raise TypeError, "\#{other.inspect} must be kind of \#{#{type_guard_value}}"
109
+ end
110
+ RUBY
111
+ end
112
+
113
+ body << <<-RUBY
114
+ #{join_attributes}
115
+ RUBY
116
+
117
+ body << <<-RUBY
118
+ end
119
+ RUBY
120
+ end
121
+ end
122
+
123
+ # Join the attributes to be checked in {#build_spaceship_method}
124
+ #
125
+ # @see [Dux::Enum::Attribute#to_comparison]
126
+ # @return [String]
127
+ def join_attributes
128
+ @attributes.map do |attribute|
129
+ attribute.to_comparison(wrap: @attributes.length > 1)
130
+ end.join('.nonzero? || ')
131
+ end
132
+
133
+ # Provides the value for type guards used by {#build_spaceship_method}
134
+ # @return [String]
135
+ def type_guard_value
136
+ raise 'Cannot get value for non-type guard' unless type_guard?
137
+
138
+ if same_type_guard?
139
+ 'self.class'
140
+ elsif specific_type_guard?
141
+ @type_guard
142
+ end
143
+ end
144
+
145
+ # @param [<Symbol, (Symbol, Symbol)>] attributes
146
+ # @return [<Dux::Enum::Attribute>]
147
+ def parse_attributes(attributes)
148
+ raise ArgumentError, "Must provide at least one attribute" if attributes.empty?
149
+
150
+ attributes.map do |attribute|
151
+ case attribute
152
+ when Symbol, String
153
+ Attribute.new(attribute, @default_sort_order)
154
+ when ATTRIBUTE_TUPLE
155
+ Attribute.new(attribute[0], validate_order(attribute[1]))
156
+ else
157
+ raise ArgumentError, "Don't know what to do with #{attribute.inspect}"
158
+ end
159
+ end.freeze
160
+ end
161
+
162
+ # @param [Symbol, String] sort_order
163
+ # @raise [ArgumentError] when given an improper sort order
164
+ # @return [Symbol]
165
+ def validate_order(sort_order)
166
+ ORDERS[sort_order]
167
+ rescue Dux::Enum::NotFound => e
168
+ raise ArgumentError, "invalid sort order: #{sort_order.inspect}"
169
+ end
170
+
171
+ # @param [Boolean, String, Symbol] type_guard
172
+ # @raise [TypeError] when given an improper type guard
173
+ # @return [Boolean, String, Symbol]
174
+ def validate_type_guard(type_guard)
175
+ case type_guard
176
+ when true, false, nil then type_guard
177
+ when Class, Module
178
+ type_guard.name
179
+ when String, Symbol, Dux::IndifferentString
180
+ type_guard.to_s
181
+ else
182
+ raise TypeError, "Don't know what to do with type guard: #{type_guard.inspect}"
183
+ end
184
+ end
185
+
186
+ # Attribute definition with sort ordering.
187
+ #
188
+ # @api private
189
+ class Attribute < Struct.new(:name, :order)
190
+ # Check if the {#order} is ascending
191
+ def ascending?
192
+ order == :asc
193
+ end
194
+
195
+ # Check if the {#order} is descending
196
+ def descending?
197
+ order == :desc
198
+ end
199
+
200
+ # @param [Boolean] many
201
+ # @param [:asc, :desc, nil] default
202
+ # @api private
203
+ # @return [String]
204
+ def to_inspect(many: false, default: nil)
205
+ ":#{name}".tap do |s|
206
+ s << " #{order.to_s.upcase}" unless many && order == default
207
+ end
208
+ end
209
+
210
+ # Generate the comparison expression used to compare
211
+ # this attribute against another.
212
+ #
213
+ # @param [Boolean] wrap if the expression should be wrapped
214
+ # in parentheses.
215
+ # @return [String]
216
+ def to_comparison(wrap: false)
217
+ if ascending?
218
+ "self.#{name} <=> other.#{name}"
219
+ elsif descending?
220
+ "other.#{name} <=> self.#{name}"
221
+ end.tap do |expression|
222
+ return "(#{expression})" if wrap
223
+ end
224
+ end
225
+ end
226
+ end
227
+
228
+ class << self
229
+ # @!group DSL
230
+
231
+ # Create {Dux::Comparable a comparable module} with the provided options.
232
+ #
233
+ # @see [Dux::Comparable#initialize]
234
+ # @return [Dux::Comparable]
235
+ def comparable(*attributes, **options)
236
+ Dux::Comparable.new(*attributes, **options)
237
+ end
238
+
239
+ # @!endgroup
240
+ end
241
+ end
@@ -12,19 +12,7 @@ module Dux
12
12
  # @param [Boolean] include_all
13
13
  # @return [Proc]
14
14
  def duckify(include_all: false)
15
- dux for_duckify, include_all: include_all
16
- end
17
-
18
- class << self
19
- # @api private
20
- # @param [Class] base
21
- # @return [void]
22
- def included(base)
23
- base.__send__ :include, Dux
24
- end
25
-
26
- alias_method :prepended, :included
27
- alias_method :extended, :included
15
+ Dux[for_duckify, include_all: include_all]
28
16
  end
29
17
  end
30
18
  end
@@ -10,22 +10,57 @@ module Dux
10
10
  class Enum
11
11
  include Enumerable
12
12
 
13
+ # {Dux::NullObject} to determine if we have no default value.
13
14
  NO_DEFAULT = Dux.null 'Dux::Enum::NO_DEFAULT', purpose: 'When no default has been provided'
14
15
 
15
16
  private_constant :NO_DEFAULT
16
17
 
17
- def initialize(*values, default: NO_DEFAULT, allow_nil: false)
18
+ # Types that can be specified for a return type
19
+ RETURN_TYPES = %i[symbol string].freeze
20
+
21
+ private_constant :RETURN_TYPES
22
+
23
+ def initialize(*values, default: NO_DEFAULT, allow_nil: false, aliases: {}, return_type: :symbol)
18
24
  @allow_nil = allow_nil
19
25
 
26
+ set_return_type return_type
27
+
20
28
  set_values values
21
29
 
22
30
  set_default default
31
+
32
+ set_aliases aliases
33
+
34
+ freeze
35
+ end
36
+
37
+ # Test for inclusion with case equality
38
+ #
39
+ # @param [String, Symbol] other
40
+ def ===(other)
41
+ include? other
23
42
  end
24
43
 
44
+ # Check if the provided key is an alias.
45
+ #
46
+ # @param [Symbol, String] key
47
+ def alias?(key)
48
+ @aliases.key? key
49
+ end
50
+
51
+ # Check if we allow nil to be returned
52
+ # as a default / fallback.
53
+ def allow_nil?
54
+ @allow_nil
55
+ end
56
+
57
+ # Check if a default is set for this enum.
25
58
  def default?
26
59
  @default != NO_DEFAULT
27
60
  end
28
61
 
62
+ # @yield [value] each member of the enum
63
+ # @yieldparam [Dux::IndifferentString] value
29
64
  def each
30
65
  return enum_for(__method__) unless block_given?
31
66
 
@@ -34,9 +69,17 @@ module Dux
34
69
  end
35
70
  end
36
71
 
72
+ # @param [String, Symbol] value
73
+ # @param [String, Symbol] fallback
74
+ # @yield Executed in lieu of raising {Dux::Enum::NotFound} if there is an unknown member
75
+ # @raise [Dux::Enum::InvalidFallback] when provided with an invalid override fallback value
76
+ # @raise [Dux::Enum::NotFound] when fetching a value not found in the enum
77
+ # @return [Symbol]
37
78
  def fetch(value, fallback: NO_DEFAULT)
38
79
  if include? value
39
- value
80
+ with_return_type value
81
+ elsif alias?(value)
82
+ with_return_type @aliases.fetch(value)
40
83
  elsif fallback != NO_DEFAULT
41
84
  if valid_fallback?(fallback)
42
85
  fallback
@@ -44,19 +87,20 @@ module Dux
44
87
  raise InvalidFallback, "Cannot use #{fallback.inspect} as a fallback"
45
88
  end
46
89
  elsif default?
47
- @default
90
+ with_return_type @default
48
91
  else
49
- raise NotFound, "Invalid enum member: #{value.inspect}"
92
+ if block_given?
93
+ yield value
94
+ else
95
+ raise NotFound, "Invalid enum member: #{value.inspect}"
96
+ end
50
97
  end
51
98
  end
52
99
 
53
100
  alias_method :[], :fetch
54
101
 
102
+ # @return [String]
55
103
  def inspect
56
- inspection = [
57
- @values.to_a.inspect
58
- ]
59
-
60
104
  "Dux::Enum(#{@values.to_a.inspect})"
61
105
  end
62
106
 
@@ -65,21 +109,69 @@ module Dux
65
109
 
66
110
  private
67
111
 
112
+ # Ensure the value is returned with the correct type
113
+ # @param [String, Symbol, nil] value
114
+ # @return [String, Symbol, nil]
115
+ def with_return_type(value)
116
+ if value.nil? && allow_nil?
117
+ if allow_nil?
118
+ nil
119
+ else
120
+ # :nocov:
121
+ raise ArgumentError, "Cannot return `nil` without allow_nil: true"
122
+ # :nocov:
123
+ end
124
+ elsif @return_type == :symbol
125
+ value.to_sym
126
+ elsif @return_type == :string
127
+ value.to_s
128
+ end
129
+ end
130
+
131
+ # @param [Symbol] return_type
132
+ # @raise [ArgumentError] on improper return type
133
+ # @return [void]
134
+ def set_return_type(return_type)
135
+ if RETURN_TYPES.include? return_type
136
+ @return_type = return_type
137
+ else
138
+ raise ArgumentError, "Invalid return type: #{return_type.inspect}", caller
139
+ end
140
+ end
141
+
142
+ # @param [<String, Symbol>] values
143
+ # @raise [TypeError] if provided with no values
144
+ # @return [void]
68
145
  def set_values(values)
69
146
  raise TypeError, "Must provide some values", caller if values.empty?
70
147
 
71
148
  @values = values.flatten.map do |value|
72
149
  Dux::IndifferentString.new value
73
- end.to_set
150
+ end.to_set.freeze
151
+ end
152
+
153
+ # @param [{Symbol => String, Symbol}] aliases
154
+ # @return [void]
155
+ def set_aliases(aliases)
156
+ raise TypeError, 'Aliases must be a hash' unless aliases.kind_of?(Hash)
157
+
158
+ @aliases = AliasMap.new *self, **aliases
74
159
  end
75
160
 
76
- def valid_fallback?(fallback, default: false)
77
- return true if fallback.nil? && @allow_nil
161
+ # Check if the provided `fallback` is valid
162
+ #
163
+ # @param [String, Symbol, nil] fallback
164
+ def valid_fallback?(fallback)
165
+ return true if fallback.nil? && allow_nil?
78
166
  return true if include? fallback
79
167
 
80
168
  false
81
169
  end
82
170
 
171
+ # Set the default fallback value.
172
+ #
173
+ # @param [String, Symbol, nil] fallback
174
+ # @return [void]
83
175
  def set_default(fallback)
84
176
  if valid_fallback?(fallback) || fallback == NO_DEFAULT
85
177
  @default = fallback
@@ -92,7 +184,98 @@ module Dux
92
184
  class NotFound < StandardError
93
185
  end
94
186
 
187
+ # Raised when attempting to use an invalid
188
+ # value as a fallback
95
189
  class InvalidFallback < ArgumentError
96
190
  end
191
+
192
+ # Raised when trying to set an already-defined
193
+ # member as an alias
194
+ class MemberAsAliasError < ArgumentError
195
+ end
196
+
197
+ # Raised when trying to set an invalid alias
198
+ # target
199
+ class InvalidAliasTargetError < ArgumentError
200
+ end
201
+
202
+ # An indifferent, read-only hash-like object
203
+ # for mapping aliases.
204
+ #
205
+ # @api private
206
+ class AliasMap
207
+ # @param [<Dux::IndifferentString>] targets
208
+ # @param [{Symbol => String, Symbol}] aliases
209
+ def initialize(*targets, **aliases)
210
+ @mapping = aliases.each_with_object({}) do |(aliaz, target), mapping|
211
+ aliaz = indifferentize(aliaz)
212
+ target = indifferentize(target)
213
+
214
+ if targets.include? aliaz
215
+ raise MemberAsAliasError, "alias `#{aliaz}` is already an enum member"
216
+ end
217
+
218
+ unless targets.include? target
219
+ raise InvalidAliasTargetError, "alias target `#{target}` is not an enum member"
220
+ end
221
+
222
+ mapping[aliaz] = target
223
+ end.freeze
224
+
225
+ @aliases = @mapping.keys.freeze
226
+
227
+ freeze
228
+ end
229
+
230
+ def alias?(aliaz)
231
+ @aliases.include? aliaz
232
+ end
233
+
234
+ alias_method :key?, :alias?
235
+
236
+ # @param [String, Symbol] aliaz
237
+ # @return [Dux::IndifferentString]
238
+ def fetch(aliaz)
239
+ @mapping[indifferentize(aliaz)]
240
+ end
241
+
242
+ alias_method :[], :fetch
243
+
244
+ # @return [Hash]
245
+ def to_h
246
+ # :nocov:
247
+ @mapping.to_h
248
+ # :nocov:
249
+ end
250
+
251
+ private
252
+
253
+ # Check if the value is acceptable for mapping.
254
+ def acceptable?(value)
255
+ value.kind_of?(String) || value.kind_of?(Symbol) || value.kind_of?(Dux::IndifferentString)
256
+ end
257
+
258
+ # @param [String, Symbol] value
259
+ # @return [Dux::IndifferentString]
260
+ def indifferentize(value)
261
+ raise TypeError, "invalid aliaz or target: #{value.inspect}" unless acceptable?(value)
262
+
263
+ Dux::IndifferentString.new value
264
+ end
265
+ end
266
+ end
267
+
268
+ class << self
269
+ # @!group DSL
270
+
271
+ # Create {Dux::Enum an enum} with the provided options.
272
+ #
273
+ # @see Dux::Enum#initialize
274
+ # @return [Dux::Enum]
275
+ def enum(*values, **options)
276
+ Dux::Enum.new(*values, **options)
277
+ end
278
+
279
+ # @!endgroup
97
280
  end
98
281
  end