dux 0.3.0 → 0.8.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.
@@ -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