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.
- checksums.yaml +4 -4
- data/.travis.yml +4 -4
- data/.yardopts +3 -0
- data/README.md +188 -11
- data/dux.gemspec +2 -2
- data/lib/dux.rb +13 -169
- data/lib/dux/blankness.rb +9 -2
- data/lib/dux/comparable.rb +241 -0
- data/lib/dux/duckify.rb +1 -13
- data/lib/dux/enum.rb +194 -11
- data/lib/dux/indifferent_string.rb +9 -1
- data/lib/dux/inspect_id.rb +6 -1
- data/lib/dux/monkey_patch.rb +97 -0
- data/lib/dux/null_object.rb +52 -5
- data/lib/dux/predicate.rb +139 -0
- data/lib/dux/version.rb +1 -1
- metadata +7 -5
- data/lib/dux/duck_type.rb +0 -0
data/lib/dux/blankness.rb
CHANGED
@@ -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
|
data/lib/dux/duckify.rb
CHANGED
@@ -12,19 +12,7 @@ module Dux
|
|
12
12
|
# @param [Boolean] include_all
|
13
13
|
# @return [Proc]
|
14
14
|
def duckify(include_all: false)
|
15
|
-
|
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
|
data/lib/dux/enum.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
77
|
-
|
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
|