dry-configurable 0.11.6 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,11 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'concurrent/map'
4
-
5
- require 'dry/equalizer'
6
-
7
- require 'dry/configurable/constants'
8
- require 'dry/configurable/errors'
3
+ require "set"
9
4
 
10
5
  module Dry
11
6
  module Configurable
@@ -19,12 +14,29 @@ module Dry
19
14
  attr_reader :_settings
20
15
 
21
16
  # @api private
22
- attr_reader :_resolved
17
+ attr_reader :_values
18
+
19
+ # @api private
20
+ attr_reader :_configured
21
+ protected :_configured
23
22
 
24
23
  # @api private
25
- def initialize(settings)
26
- @_settings = settings.dup
27
- @_resolved = Concurrent::Map.new
24
+ def initialize(settings, values: {})
25
+ @_settings = settings
26
+ @_values = values
27
+ @_configured = Set.new
28
+ end
29
+
30
+ # @api private
31
+ private def initialize_copy(source)
32
+ super
33
+ @_values = source.__send__(:dup_values)
34
+ @_configured = source._configured.dup
35
+ end
36
+
37
+ # @api private
38
+ def dup_for_settings(settings)
39
+ dup.tap { |config| config.instance_variable_set(:@_settings, settings) }
28
40
  end
29
41
 
30
42
  # Get config value by a key
@@ -34,9 +46,19 @@ module Dry
34
46
  # @return Config value
35
47
  def [](name)
36
48
  name = name.to_sym
37
- raise ArgumentError, "+#{name}+ is not a setting name" unless _settings.key?(name)
38
49
 
39
- _settings[name].value
50
+ unless (setting = _settings[name])
51
+ raise ArgumentError, "+#{name}+ is not a setting name"
52
+ end
53
+
54
+ _values.fetch(name) {
55
+ # Mutable settings may be configured after read
56
+ _configured.add(name) if setting.cloneable?
57
+
58
+ setting.to_value.tap { |value|
59
+ _values[name] = value
60
+ }
61
+ }
40
62
  end
41
63
 
42
64
  # Set config value.
@@ -45,21 +67,34 @@ module Dry
45
67
  # @param [String,Symbol] name
46
68
  # @param [Object] value
47
69
  def []=(name, value)
48
- public_send(:"#{name}=", value)
70
+ raise FrozenConfigError, "Cannot modify frozen config" if frozen?
71
+
72
+ name = name.to_sym
73
+
74
+ unless (setting = _settings[name])
75
+ raise ArgumentError, "+#{name}+ is not a setting name"
76
+ end
77
+
78
+ _configured.add(name)
79
+
80
+ _values[name] = setting.constructor.(value)
49
81
  end
50
82
 
51
83
  # Update config with new values
52
84
  #
53
- # @param values [Hash] A hash with new values
85
+ # @param values [Hash, #to_hash] A hash with new values
54
86
  #
55
87
  # @return [Config]
56
88
  #
57
89
  # @api public
58
90
  def update(values)
59
91
  values.each do |key, value|
60
- case value
61
- when Hash
62
- self[key].update(value)
92
+ if self[key].is_a?(self.class)
93
+ unless value.respond_to?(:to_hash)
94
+ raise ArgumentError, "#{value.inspect} is not a valid setting value"
95
+ end
96
+
97
+ self[key].update(value.to_hash)
63
98
  else
64
99
  self[key] = value
65
100
  end
@@ -67,62 +102,113 @@ module Dry
67
102
  self
68
103
  end
69
104
 
70
- # Dump config into a hash
105
+ # Returns true if the value for the given key has been set on this config.
106
+ #
107
+ # For simple values, this returns true if the value has been explicitly assigned.
108
+ #
109
+ # For cloneable (mutable) values, since these are captured on read, returns true if the value
110
+ # does not compare equally to its corresdponing default value. This relies on these objects
111
+ # having functioning `#==` checks.
112
+ #
113
+ # @return [Bool]
114
+ #
115
+ # @api public
116
+ def configured?(key)
117
+ if _configured.include?(key) && _settings[key].cloneable?
118
+ return _values[key] != _settings[key].to_value
119
+ end
120
+
121
+ _configured.include?(key)
122
+ end
123
+
124
+ # Returns the current config values.
125
+ #
126
+ # Nested configs remain in their {Config} instances.
71
127
  #
72
128
  # @return [Hash]
73
129
  #
74
130
  # @api public
75
131
  def values
76
- _settings
77
- .map { |setting| [setting.name, setting.value] }
78
- .map { |key, value| [key, value.is_a?(self.class) ? value.to_h : value] }
79
- .to_h
132
+ # Ensure all settings are represented in values
133
+ _settings.each { |setting| self[setting.name] unless _values.key?(setting.name) }
134
+
135
+ _values
80
136
  end
81
- alias_method :to_h, :values
82
- alias_method :to_hash, :values
83
137
 
84
- # @api private
85
- def finalize!
86
- _settings.freeze
87
- freeze
138
+ # Returns config values as a hash, with nested values also converted from {Config} instances
139
+ # into hashes.
140
+ #
141
+ # @return [Hash]
142
+ #
143
+ # @api public
144
+ def to_h
145
+ values.to_h { |key, value| [key, value.is_a?(self.class) ? value.to_h : value] }
88
146
  end
89
147
 
90
148
  # @api private
91
- def pristine
92
- self.class.new(_settings.pristine)
149
+ alias_method :_dry_equalizer_hash, :hash
150
+
151
+ # @api public
152
+ def hash
153
+ return @__hash__ if instance_variable_defined?(:@__hash__)
154
+
155
+ _dry_equalizer_hash
156
+ end
157
+
158
+ # @api public
159
+ def finalize!(freeze_values: false)
160
+ return self if frozen?
161
+
162
+ values.each_value do |value|
163
+ if value.is_a?(self.class)
164
+ value.finalize!(freeze_values: freeze_values)
165
+ elsif freeze_values
166
+ value.freeze
167
+ end
168
+ end
169
+
170
+ # Memoize the hash for the object when finalizing (regardless of whether values themselves
171
+ # are to be frozen; the intention of finalization is that no further changes should be
172
+ # made). The benefit of freezing the hash at this point is that it saves repeated expensive
173
+ # computation (through Dry::Equalizer's hash implementation) if that hash is to be used
174
+ # later in performance-sensitive situations, such as when serving as a cache key or similar.
175
+ @__hash__ = _dry_equalizer_hash
176
+
177
+ freeze
93
178
  end
94
179
 
95
180
  # @api private
96
- def respond_to_missing?(meth, include_private = false)
97
- super || _settings.key?(resolve(meth))
181
+ def pristine
182
+ self.class.new(_settings)
98
183
  end
99
184
 
100
185
  private
101
186
 
102
- # @api private
103
- def method_missing(meth, *args)
104
- setting = _settings[resolve(meth)]
187
+ def method_missing(name, *args)
188
+ setting_name = setting_name_from_method(name)
189
+ setting = _settings[setting_name]
105
190
 
106
191
  super unless setting
107
192
 
108
- if setting.writer?(meth)
109
- raise FrozenConfig, 'Cannot modify frozen config' if frozen?
110
-
111
- _settings << setting.with(input: args[0])
193
+ if name.end_with?("=")
194
+ self[setting_name] = args[0]
112
195
  else
113
- setting.value
196
+ self[setting_name]
114
197
  end
115
198
  end
116
199
 
117
- # @api private
118
- def resolve(meth)
119
- _resolved.fetch(meth) { _resolved[meth] = meth.to_s.tr('=', '').to_sym }
200
+ def respond_to_missing?(meth, include_private = false)
201
+ _settings.key?(setting_name_from_method(meth)) || super
120
202
  end
121
203
 
122
- # @api private
123
- def initialize_copy(source)
124
- super
125
- @_settings = source._settings.dup
204
+ def setting_name_from_method(method_name)
205
+ method_name.to_s.tr("=", "").to_sym
206
+ end
207
+
208
+ def dup_values
209
+ _values.each_with_object({}) { |(key, val), dup_hsh|
210
+ dup_hsh[key] = _settings[key].cloneable? ? val.dup : val
211
+ }
126
212
  end
127
213
  end
128
214
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/core/constants'
4
-
5
3
  module Dry
6
4
  # Shared constants
7
5
  #
@@ -1,11 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/configurable/constants'
4
- require 'dry/configurable/setting'
5
- require 'dry/configurable/settings'
6
- require 'dry/configurable/compiler'
7
- require 'dry/configurable/dsl/args'
8
-
9
3
  module Dry
10
4
  module Configurable
11
5
  # Setting DSL used by the class API
@@ -14,49 +8,67 @@ module Dry
14
8
  class DSL
15
9
  VALID_NAME = /\A[a-z_]\w*\z/i.freeze
16
10
 
17
- # @api private
18
11
  attr_reader :compiler
19
12
 
20
- # @api private
21
13
  attr_reader :ast
22
14
 
23
- # @api private
24
- def initialize(&block)
15
+ attr_reader :options
16
+
17
+ def initialize(**options, &block)
25
18
  @compiler = Compiler.new
26
19
  @ast = []
20
+ @options = options
27
21
  instance_exec(&block) if block
28
22
  end
29
23
 
30
- # Register a new setting node and compile it into a setting object
24
+ # Registers a new setting node and compile it into a setting object
31
25
  #
32
26
  # @see ClassMethods.setting
33
- # @api public
27
+ # @api private
34
28
  # @return Setting
35
- def setting(name, *args, &block)
29
+ def setting(name, **options, &block) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
36
30
  unless VALID_NAME.match?(name.to_s)
37
31
  raise ArgumentError, "#{name} is not a valid setting name"
38
32
  end
39
33
 
40
- args = Args.new(args)
41
-
42
- args.ensure_valid_options
34
+ ensure_valid_options(options)
43
35
 
44
- default, opts = args
36
+ options = {default: default, config_class: config_class, **options}
45
37
 
46
- node = [:setting, [name.to_sym, default, opts == default ? EMPTY_HASH : opts]]
38
+ node = [:setting, [name.to_sym, options]]
47
39
 
48
40
  if block
49
- if block.arity.zero?
50
- ast << [:nested, [node, DSL.new(&block).ast]]
51
- else
52
- ast << [:constructor, [node, block]]
53
- end
41
+ ast << [:nested, [node, DSL.new(&block).ast]]
54
42
  else
55
43
  ast << node
56
44
  end
57
45
 
58
46
  compiler.visit(ast.last)
59
47
  end
48
+
49
+ def config_class
50
+ options[:config_class]
51
+ end
52
+
53
+ def default
54
+ options[:default_undefined] ? Undefined : nil
55
+ end
56
+
57
+ private
58
+
59
+ def ensure_valid_options(options)
60
+ return if options.none?
61
+
62
+ invalid_keys = options.keys - Setting::OPTIONS
63
+
64
+ raise ArgumentError, "Invalid options: #{invalid_keys.inspect}" unless invalid_keys.empty?
65
+ end
66
+
67
+ # Returns a tuple of valid and invalid options hashes derived from the options hash
68
+ # given to the setting
69
+ def valid_and_invalid_options(options)
70
+ options.partition { |k, _| Setting::OPTIONS.include?(k) }.map(&:to_h)
71
+ end
60
72
  end
61
73
  end
62
74
  end
@@ -6,7 +6,8 @@ module Dry
6
6
  # @api public
7
7
  module Configurable
8
8
  Error = Class.new(::StandardError)
9
- AlreadyIncluded = ::Class.new(Error)
10
- FrozenConfig = ::Class.new(Error)
9
+
10
+ AlreadyIncludedError = Class.new(Error)
11
+ FrozenConfigError = Class.new(Error)
11
12
  end
12
13
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Configurable
5
+ class Extension < Module
6
+ # @api private
7
+ attr_reader :config_class
8
+
9
+ # @api private
10
+ attr_reader :default_undefined
11
+
12
+ # @api private
13
+ def initialize(config_class: Configurable::Config, default_undefined: false)
14
+ super()
15
+ @config_class = config_class
16
+ @default_undefined = default_undefined
17
+ freeze
18
+ end
19
+
20
+ # @api private
21
+ def extended(klass)
22
+ super
23
+ klass.extend(ClassMethods)
24
+ klass.instance_variable_set(:@__config_extension__, self)
25
+ end
26
+
27
+ # @api private
28
+ def included(klass)
29
+ raise AlreadyIncludedError if klass.include?(InstanceMethods)
30
+
31
+ super
32
+
33
+ klass.class_eval do
34
+ extend(ClassMethods)
35
+ include(InstanceMethods)
36
+ prepend(Initializer)
37
+
38
+ class << self
39
+ undef :config
40
+ undef :configure
41
+ end
42
+ end
43
+
44
+ klass.instance_variable_set(:@__config_extension__, self)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,10 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/configurable/config'
4
- require 'dry/configurable/methods'
3
+ require "dry/configurable/config"
4
+ require "dry/configurable/methods"
5
5
 
6
6
  module Dry
7
7
  module Configurable
8
+ # Initializer method which is prepended when `Dry::Configurable`
9
+ # is included in a class
10
+ #
11
+ # @api private
12
+ module Initializer
13
+ # @api private
14
+ def initialize(*)
15
+ @__config__ = self.class.__config_build__(self.class.settings)
16
+
17
+ super
18
+ end
19
+ ruby2_keywords(:initialize) if respond_to?(:ruby2_keywords, true)
20
+ end
21
+
8
22
  # Instance-level API when `Dry::Configurable` is included in a class
9
23
  #
10
24
  # @api public
@@ -16,20 +30,14 @@ module Dry
16
30
  # @return [Config]
17
31
  #
18
32
  # @api public
19
- attr_reader :config
20
-
21
- # @api private
22
- def initialize(*)
23
- @config = Config.new(self.class._settings.dup)
24
- super
33
+ def config
34
+ @__config__
25
35
  end
26
36
 
27
37
  # Finalize the config and freeze the object
28
38
  #
29
39
  # @api public
30
- def finalize!
31
- return self if frozen?
32
-
40
+ def finalize!(freeze_values: false)
33
41
  super
34
42
  freeze
35
43
  end
@@ -39,7 +47,7 @@ module Dry
39
47
  # @api public
40
48
  def initialize_copy(source)
41
49
  super
42
- @config = source.config.dup
50
+ @__config__ = source.config.dup
43
51
  end
44
52
  end
45
53
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/configurable/errors'
4
-
5
3
  module Dry
6
4
  module Configurable
7
5
  # Common API for both classes and instances
@@ -10,7 +8,7 @@ module Dry
10
8
  module Methods
11
9
  # @api public
12
10
  def configure(&block)
13
- raise FrozenConfig, 'Cannot modify frozen config' if frozen?
11
+ raise FrozenConfigError, "Cannot modify frozen config" if config.frozen?
14
12
 
15
13
  yield(config) if block
16
14
  self
@@ -21,10 +19,8 @@ module Dry
21
19
  # @return [Dry::Configurable::Config]
22
20
  #
23
21
  # @api public
24
- def finalize!
25
- return self if config.frozen?
26
-
27
- config.finalize!
22
+ def finalize!(freeze_values: false)
23
+ config.finalize!(freeze_values: freeze_values)
28
24
  self
29
25
  end
30
26
  end
@@ -1,102 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
-
5
- require 'dry/equalizer'
6
-
7
- require 'dry/configurable/constants'
8
- require 'dry/configurable/config'
3
+ require "set"
9
4
 
10
5
  module Dry
11
6
  module Configurable
12
- # This class represents a setting and is used internally.
7
+ # A defined setting.
13
8
  #
14
- # @api private
9
+ # @api public
15
10
  class Setting
16
- include Dry::Equalizer(:name, :value, :options, inspect: false)
11
+ include Dry::Equalizer(:name, :default, :constructor, :children, :options, inspect: false)
17
12
 
18
- OPTIONS = %i[input default reader constructor settings].freeze
13
+ OPTIONS = %i[default reader constructor mutable cloneable settings config_class].freeze
19
14
 
20
15
  DEFAULT_CONSTRUCTOR = -> v { v }.freeze
21
16
 
22
- CLONABLE_VALUE_TYPES = [Array, Hash, Set, Config].freeze
17
+ MUTABLE_VALUE_TYPES = [Array, Hash, Set, Config].freeze
23
18
 
24
- # @api private
19
+ # @api public
25
20
  attr_reader :name
26
21
 
27
- # @api private
28
- attr_reader :writer_name
22
+ # @api public
23
+ attr_reader :default
29
24
 
30
- # @api private
31
- attr_reader :input
25
+ # @api public
26
+ attr_reader :mutable
32
27
 
33
- # @api private
34
- attr_reader :default
28
+ # @api public
29
+ attr_reader :constructor
35
30
 
36
- # @api private
31
+ # @api public
32
+ attr_reader :children
33
+
34
+ # @api public
37
35
  attr_reader :options
38
36
 
39
- # Specialized Setting which includes nested settings
40
- #
41
37
  # @api private
42
- class Nested < Setting
43
- CONSTRUCTOR = Config.method(:new)
44
-
45
- # @api private
46
- def pristine
47
- with(input: input.pristine)
48
- end
49
-
50
- # @api private
51
- def constructor
52
- CONSTRUCTOR
53
- end
38
+ def self.mutable_value?(value)
39
+ MUTABLE_VALUE_TYPES.any? { |type| value.is_a?(type) }
54
40
  end
55
41
 
56
42
  # @api private
57
- def initialize(name, input: Undefined, default: Undefined, **options)
43
+ def initialize(
44
+ name,
45
+ default:,
46
+ constructor: DEFAULT_CONSTRUCTOR,
47
+ children: EMPTY_ARRAY,
48
+ **options
49
+ )
58
50
  @name = name
59
- @writer_name = :"#{name}="
60
- @input = input.equal?(Undefined) ? default : input
61
51
  @default = default
52
+ @mutable = children.any? || options.fetch(:mutable) {
53
+ # Allow `cloneable` as an option alias for `mutable`
54
+ options.fetch(:cloneable) { Setting.mutable_value?(default) }
55
+ }
56
+ @constructor = constructor
57
+ @children = children
62
58
  @options = options
63
-
64
- evaluate if input_defined?
65
- end
66
-
67
- # @api private
68
- def input_defined?
69
- !input.equal?(Undefined)
70
- end
71
-
72
- # @api private
73
- def value
74
- @value ||= evaluate
75
- end
76
-
77
- # @api private
78
- def evaluated?
79
- instance_variable_defined?(:@value)
80
- end
81
-
82
- # @api private
83
- def nested(settings)
84
- Nested.new(name, input: settings, **options)
85
- end
86
-
87
- # @api private
88
- def pristine
89
- with(input: Undefined)
90
- end
91
-
92
- # @api private
93
- def with(new_opts)
94
- self.class.new(name, input: input, default: default, **options, **new_opts)
95
- end
96
-
97
- # @api private
98
- def constructor
99
- options[:constructor] || DEFAULT_CONSTRUCTOR
100
59
  end
101
60
 
102
61
  # @api private
@@ -104,28 +63,22 @@ module Dry
104
63
  options[:reader].equal?(true)
105
64
  end
106
65
 
107
- # @api private
108
- def writer?(meth)
109
- writer_name.equal?(meth)
66
+ # @api public
67
+ def mutable?
68
+ mutable
110
69
  end
70
+ alias_method :cloneable?, :mutable?
111
71
 
112
72
  # @api private
113
- def clonable_value?
114
- CLONABLE_VALUE_TYPES.any? { |type| value.is_a?(type) }
115
- end
116
-
117
- private
73
+ def to_value
74
+ if children.any?
75
+ (options[:config_class] || Config).new(children)
76
+ else
77
+ value = default
78
+ value = constructor.(value) unless value.eql?(Undefined)
118
79
 
119
- # @api private
120
- def initialize_copy(source)
121
- super
122
- @value = source.value.dup if source.input_defined? && source.clonable_value?
123
- @options = source.options.dup
124
- end
125
-
126
- # @api private
127
- def evaluate
128
- @value = constructor[input.equal?(Undefined) ? nil : input]
80
+ mutable? ? value.dup : value
81
+ end
129
82
  end
130
83
  end
131
84
  end