dry-initializer 3.0.1

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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +23 -0
  3. data/.gitignore +10 -0
  4. data/.rspec +4 -0
  5. data/.rubocop.yml +51 -0
  6. data/.travis.yml +28 -0
  7. data/CHANGELOG.md +883 -0
  8. data/Gemfile +29 -0
  9. data/Guardfile +5 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +90 -0
  12. data/Rakefile +8 -0
  13. data/benchmarks/compare_several_defaults.rb +82 -0
  14. data/benchmarks/plain_options.rb +63 -0
  15. data/benchmarks/plain_params.rb +84 -0
  16. data/benchmarks/with_coercion.rb +71 -0
  17. data/benchmarks/with_defaults.rb +66 -0
  18. data/benchmarks/with_defaults_and_coercion.rb +59 -0
  19. data/dry-initializer.gemspec +20 -0
  20. data/lib/dry-initializer.rb +1 -0
  21. data/lib/dry/initializer.rb +61 -0
  22. data/lib/dry/initializer/builders.rb +7 -0
  23. data/lib/dry/initializer/builders/attribute.rb +81 -0
  24. data/lib/dry/initializer/builders/initializer.rb +61 -0
  25. data/lib/dry/initializer/builders/reader.rb +50 -0
  26. data/lib/dry/initializer/builders/signature.rb +32 -0
  27. data/lib/dry/initializer/config.rb +184 -0
  28. data/lib/dry/initializer/definition.rb +65 -0
  29. data/lib/dry/initializer/dispatchers.rb +112 -0
  30. data/lib/dry/initializer/dispatchers/build_nested_type.rb +59 -0
  31. data/lib/dry/initializer/dispatchers/check_type.rb +43 -0
  32. data/lib/dry/initializer/dispatchers/prepare_default.rb +40 -0
  33. data/lib/dry/initializer/dispatchers/prepare_ivar.rb +12 -0
  34. data/lib/dry/initializer/dispatchers/prepare_optional.rb +13 -0
  35. data/lib/dry/initializer/dispatchers/prepare_reader.rb +30 -0
  36. data/lib/dry/initializer/dispatchers/prepare_source.rb +28 -0
  37. data/lib/dry/initializer/dispatchers/prepare_target.rb +44 -0
  38. data/lib/dry/initializer/dispatchers/unwrap_type.rb +22 -0
  39. data/lib/dry/initializer/dispatchers/wrap_type.rb +27 -0
  40. data/lib/dry/initializer/dsl.rb +43 -0
  41. data/lib/dry/initializer/mixin.rb +15 -0
  42. data/lib/dry/initializer/mixin/local.rb +19 -0
  43. data/lib/dry/initializer/mixin/root.rb +10 -0
  44. data/lib/dry/initializer/struct.rb +40 -0
  45. data/lib/dry/initializer/undefined.rb +2 -0
  46. data/lib/tasks/benchmark.rake +41 -0
  47. data/lib/tasks/profile.rake +78 -0
  48. data/spec/attributes_spec.rb +38 -0
  49. data/spec/coercion_of_nil_spec.rb +25 -0
  50. data/spec/custom_dispatchers_spec.rb +35 -0
  51. data/spec/custom_initializer_spec.rb +30 -0
  52. data/spec/default_values_spec.rb +83 -0
  53. data/spec/definition_spec.rb +111 -0
  54. data/spec/invalid_default_spec.rb +13 -0
  55. data/spec/list_type_spec.rb +32 -0
  56. data/spec/missed_default_spec.rb +14 -0
  57. data/spec/nested_type_spec.rb +44 -0
  58. data/spec/optional_spec.rb +71 -0
  59. data/spec/options_tolerance_spec.rb +11 -0
  60. data/spec/public_attributes_utility_spec.rb +22 -0
  61. data/spec/reader_spec.rb +87 -0
  62. data/spec/repetitive_definitions_spec.rb +69 -0
  63. data/spec/several_assignments_spec.rb +41 -0
  64. data/spec/spec_helper.rb +22 -0
  65. data/spec/subclassing_spec.rb +49 -0
  66. data/spec/type_argument_spec.rb +35 -0
  67. data/spec/type_constraint_spec.rb +78 -0
  68. data/spec/value_coercion_via_dry_types_spec.rb +29 -0
  69. metadata +189 -0
@@ -0,0 +1,184 @@
1
+ module Dry::Initializer
2
+ #
3
+ # Gem-related configuration of some class
4
+ #
5
+ class Config
6
+ # @!attribute [r] null
7
+ # @return [Dry::Initializer::UNDEFINED, nil] value of unassigned variable
8
+
9
+ # @!attribute [r] extended_class
10
+ # @return [Class] the class whose config collected by current object
11
+
12
+ # @!attribute [r] parent
13
+ # @return [Dry::Initializer::Config] parent configuration
14
+
15
+ # @!attribute [r] definitions
16
+ # @return [Hash<Symbol, Dry::Initializer::Definition>]
17
+ # hash of attribute definitions with their source names
18
+
19
+ attr_reader :null, :extended_class, :parent, :definitions
20
+
21
+ # @!attribute [r] mixin
22
+ # @return [Module] reference to the module to be included into class
23
+ def mixin
24
+ @mixin ||= Module.new.tap do |mod|
25
+ __dry_initializer__ = self
26
+ mod.extend(Mixin::Local)
27
+ mod.send :define_method, :__dry_initializer_config__ do
28
+ __dry_initializer__
29
+ end
30
+ mod.send :private, :__dry_initializer_config__
31
+ end
32
+ end
33
+
34
+ # List of configs of all subclasses of the [#extended_class]
35
+ # @return [Array<Dry::Initializer::Config>]
36
+ def children
37
+ @children ||= Set.new
38
+ end
39
+
40
+ # List of definitions for initializer params
41
+ # @return [Array<Dry::Initializer::Definition>]
42
+ def params
43
+ definitions.values.reject(&:option)
44
+ end
45
+
46
+ # List of definitions for initializer options
47
+ # @return [Array<Dry::Initializer::Definition>]
48
+ def options
49
+ definitions.values.select(&:option)
50
+ end
51
+
52
+ # Adds or redefines a parameter
53
+ # @param [Symbol] name
54
+ # @param [#call, nil] type (nil)
55
+ # @option opts [Proc] :default
56
+ # @option opts [Boolean] :optional
57
+ # @option opts [Symbol] :as
58
+ # @option opts [true, false, :protected, :public, :private] :reader
59
+ # @return [self] itself
60
+ def param(name, type = nil, **opts, &block)
61
+ add_definition(false, name, type, block, opts)
62
+ end
63
+
64
+ # Adds or redefines an option of [#dry_initializer]
65
+ #
66
+ # @param (see #param)
67
+ # @option (see #param)
68
+ # @return (see #param)
69
+ #
70
+ def option(name, type = nil, **opts, &block)
71
+ add_definition(true, name, type, block, opts)
72
+ end
73
+
74
+ # The hash of public attributes for an instance of the [#extended_class]
75
+ # @param [Dry::Initializer::Instance] instance
76
+ # @return [Hash<Symbol, Object>]
77
+ def public_attributes(instance)
78
+ definitions.values.each_with_object({}) do |item, obj|
79
+ key = item.target
80
+ next unless instance.respond_to? key
81
+ val = instance.send(key)
82
+ obj[key] = val unless null == val
83
+ end
84
+ end
85
+
86
+ # The hash of assigned attributes for an instance of the [#extended_class]
87
+ # @param [Dry::Initializer::Instance] instance
88
+ # @return [Hash<Symbol, Object>]
89
+ def attributes(instance)
90
+ definitions.values.each_with_object({}) do |item, obj|
91
+ key = item.target
92
+ val = instance.send(:instance_variable_get, item.ivar)
93
+ obj[key] = val unless null == val
94
+ end
95
+ end
96
+
97
+ # Code of the `#__initialize__` method
98
+ # @return [String]
99
+ def code
100
+ Builders::Initializer[self]
101
+ end
102
+
103
+ # Finalizes config
104
+ # @return [self]
105
+ def finalize
106
+ @definitions = final_definitions
107
+ check_order_of_params
108
+ mixin.class_eval(code)
109
+ children.each(&:finalize)
110
+ self
111
+ end
112
+
113
+ # Human-readable representation of configured params and options
114
+ # @return [String]
115
+ def inch
116
+ line = Builders::Signature[self]
117
+ line = line.gsub("__dry_initializer_options__", "options")
118
+ lines = ["@!method initialize(#{line})"]
119
+ lines += ["Initializes an instance of #{extended_class}"]
120
+ lines += definitions.values.map(&:inch)
121
+ lines += ["@return [#{extended_class}]"]
122
+ lines.join("\n")
123
+ end
124
+
125
+ private
126
+
127
+ def initialize(extended_class = nil, null: UNDEFINED)
128
+ @extended_class = extended_class.tap { |klass| klass&.include mixin }
129
+ sklass = extended_class&.superclass
130
+ @parent = sklass.dry_initializer if sklass.is_a? Dry::Initializer
131
+ @null = null || parent&.null
132
+ @definitions = {}
133
+ finalize
134
+ end
135
+
136
+ # rubocop: disable Metrics/MethodLength
137
+ def add_definition(option, name, type, block, **opts)
138
+ opts = {
139
+ parent: extended_class,
140
+ option: option,
141
+ null: null,
142
+ source: name,
143
+ type: type,
144
+ block: block,
145
+ **opts,
146
+ }
147
+
148
+ options = Dispatchers.call(opts)
149
+ definition = Definition.new(options)
150
+ definitions[definition.source] = definition
151
+ finalize
152
+ mixin.class_eval definition.code
153
+ end
154
+ # rubocop: enable Metrics/MethodLength
155
+
156
+ def final_definitions
157
+ parent_definitions = Hash(parent&.definitions&.dup)
158
+ definitions.each_with_object(parent_definitions) do |(key, val), obj|
159
+ obj[key] = check_type(obj[key], val)
160
+ end
161
+ end
162
+
163
+ def check_type(previous, current)
164
+ return current unless previous
165
+ return current if previous.option == current.option
166
+ raise SyntaxError,
167
+ "cannot reload #{previous} of #{extended_class.superclass}" \
168
+ " by #{current} of its subclass #{extended_class}"
169
+ end
170
+
171
+ def check_order_of_params
172
+ params.inject(nil) do |optional, current|
173
+ if current.optional
174
+ current
175
+ elsif optional
176
+ raise SyntaxError, "#{extended_class}: required #{current}" \
177
+ " goes after optional #{optional}"
178
+ else
179
+ optional
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,65 @@
1
+ module Dry::Initializer
2
+ #
3
+ # @private
4
+ # @abstract
5
+ #
6
+ # Base class for parameter or option definitions
7
+ # Defines methods to add corresponding reader to the class,
8
+ # and build value of instance attribute.
9
+ #
10
+ class Definition
11
+ attr_reader :option, :null, :source, :target, :ivar,
12
+ :type, :optional, :default, :reader,
13
+ :desc
14
+
15
+ def options
16
+ {
17
+ as: target,
18
+ type: type,
19
+ optional: optional,
20
+ default: default,
21
+ reader: reader,
22
+ desc: desc
23
+ }.reject { |_, value| value.nil? }
24
+ end
25
+
26
+ def name
27
+ @name ||= (option ? "option" : "parameter") << " '#{source}'"
28
+ end
29
+ alias to_s name
30
+ alias to_str name
31
+ alias inspect name
32
+
33
+ def ==(other)
34
+ other.instance_of?(self.class) && (other.source == source)
35
+ end
36
+
37
+ def code
38
+ Builders::Reader[self]
39
+ end
40
+
41
+ def inch
42
+ @inch ||= (option ? "@option" : "@param ").tap do |text|
43
+ text << " [Object]"
44
+ text << (option ? " :#{source}" : " #{source}")
45
+ text << (optional ? " (optional)" : " (required)")
46
+ text << " #{desc}" if desc
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def initialize(**options)
53
+ @option = options[:option]
54
+ @null = options[:null]
55
+ @source = options[:source]
56
+ @target = options[:target]
57
+ @ivar = "@#{@target}"
58
+ @type = options[:type]
59
+ @reader = options[:reader]
60
+ @default = options[:default]
61
+ @optional = options[:optional]
62
+ @desc = options[:desc]
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,112 @@
1
+ #
2
+ # The module is responsible for __normalizing__ arguments
3
+ # of `.param` and `.option`.
4
+ #
5
+ # What the module does is convert the source list of arguments
6
+ # into the standard set of options:
7
+ # - `:option` -- whether an argument is an option (or param)
8
+ # - `:source` -- the name of source option
9
+ # - `:target` -- the target name of the reader
10
+ # - `:reader` -- if the reader's privacy (:public, :protected, :private, nil)
11
+ # - `:ivar` -- the target nane of the variable
12
+ # - `:type` -- the callable coercer of the source value
13
+ # - `:optional` -- if the argument is optional
14
+ # - `:default` -- the proc returning the default value of the source value
15
+ # - `:null` -- the value to be set to unassigned optional argument
16
+ #
17
+ # It is this set is used to build [Dry::Initializer::Definition].
18
+ #
19
+ # @example
20
+ # # from `option :foo, [], as: :bar, optional: :true
21
+ # input = { name: :foo, as: :bar, type: [], optional: true }
22
+ #
23
+ # Dry::Initializer::Dispatcher.call(input)
24
+ # # => {
25
+ # # source: "foo",
26
+ # # target: "bar",
27
+ # # reader: :public,
28
+ # # ivar: "@bar",
29
+ # # type: ->(v) { Array(v) } }, # simplified for brevity
30
+ # # optional: true,
31
+ # # default: -> { Dry::Initializer::UNDEFINED },
32
+ # # }
33
+ #
34
+ # # Settings
35
+ #
36
+ # The module uses global setting `null` to define what value
37
+ # should be set to variables that kept unassigned. By default it
38
+ # uses `Dry::Initializer::UNDEFINED`
39
+ #
40
+ # # Syntax Extensions
41
+ #
42
+ # The module supports syntax extensions. You can add any number
43
+ # of custom dispatchers __on top__ of the stack of default dispatchers.
44
+ # Every dispatcher should be a callable object that takes
45
+ # the source set of options and converts it to another set of options.
46
+ #
47
+ # @example Add special dispatcher
48
+ #
49
+ # # Define a dispatcher for key :integer
50
+ # dispatcher = proc do |integer: false, **opts|
51
+ # opts.merge(type: proc(&:to_i)) if integer
52
+ # end
53
+ #
54
+ # # Register a dispatcher
55
+ # Dry::Initializer::Dispatchers << dispatcher
56
+ #
57
+ # # Now you can use option `integer: true` instead of `type: proc(&:to_i)`
58
+ # class Foo
59
+ # extend Dry::Initializer
60
+ # param :id, integer: true
61
+ # end
62
+ #
63
+ module Dry::Initializer::Dispatchers
64
+ extend self
65
+
66
+ # @!attribute [rw] null Defines a value to be set to unassigned attributes
67
+ # @return [Object]
68
+ attr_accessor :null
69
+
70
+ #
71
+ # Registers a new dispatcher
72
+ #
73
+ # @param [#call] dispatcher
74
+ # @return [self] itself
75
+ #
76
+ def <<(dispatcher)
77
+ @pipeline = [dispatcher] + pipeline
78
+ self
79
+ end
80
+
81
+ #
82
+ # Normalizes the source set of options
83
+ #
84
+ # @param [Hash<Symbol, Object>] options
85
+ # @return [Hash<Symbol, Objct>] normalized set of options
86
+ #
87
+ def call(**options)
88
+ options = { null: null }.merge(options)
89
+ pipeline.reduce(options) { |opts, dispatcher| dispatcher.call(opts) }
90
+ end
91
+
92
+ private
93
+
94
+ require_relative "dispatchers/build_nested_type"
95
+ require_relative "dispatchers/check_type"
96
+ require_relative "dispatchers/prepare_default"
97
+ require_relative "dispatchers/prepare_ivar"
98
+ require_relative "dispatchers/prepare_optional"
99
+ require_relative "dispatchers/prepare_reader"
100
+ require_relative "dispatchers/prepare_source"
101
+ require_relative "dispatchers/prepare_target"
102
+ require_relative "dispatchers/unwrap_type"
103
+ require_relative "dispatchers/wrap_type"
104
+
105
+ def pipeline
106
+ @pipeline ||= [
107
+ PrepareSource, PrepareTarget, PrepareIvar, PrepareReader,
108
+ PrepareDefault, PrepareOptional,
109
+ UnwrapType, CheckType, BuildNestedType, WrapType
110
+ ]
111
+ end
112
+ end
@@ -0,0 +1,59 @@
1
+ #
2
+ # Prepare nested data type from a block
3
+ #
4
+ # @example
5
+ # option :foo do
6
+ # option :bar
7
+ # option :qux
8
+ # end
9
+ #
10
+ module Dry::Initializer::Dispatchers::BuildNestedType
11
+ extend self
12
+
13
+ # rubocop: disable Metrics/ParameterLists
14
+ def call(parent:, source:, target:, type: nil, block: nil, **options)
15
+ check_certainty!(source, type, block)
16
+ check_name!(target, block)
17
+ type ||= build_nested_type(parent, target, block)
18
+ { parent: parent, source: source, target: target, type: type, **options }
19
+ end
20
+ # rubocop: enable Metrics/ParameterLists
21
+
22
+ private
23
+
24
+ def check_certainty!(source, type, block)
25
+ return unless block
26
+ return unless type
27
+
28
+ raise ArgumentError, <<~MESSAGE
29
+ You should define coercer of values of argument '#{source}'
30
+ either though the parameter/option, or via nested block, but not the both.
31
+ MESSAGE
32
+ end
33
+
34
+ def check_name!(name, block)
35
+ return unless block
36
+ return unless name[/^_|__|_$/]
37
+
38
+ raise ArgumentError, <<~MESSAGE
39
+ The name of the argument '#{name}' cannot be used for nested struct.
40
+ A proper name can use underscores _ to divide alphanumeric parts only.
41
+ MESSAGE
42
+ end
43
+
44
+ def build_nested_type(parent, name, block)
45
+ return unless block
46
+
47
+ klass_name = full_name(parent, name)
48
+ build_struct(klass_name, block)
49
+ end
50
+
51
+ def full_name(parent, name)
52
+ "::#{parent.name}::#{name.to_s.split("_").compact.map(&:capitalize).join}"
53
+ end
54
+
55
+ def build_struct(klass_name, block)
56
+ eval "class #{klass_name} < Dry::Initializer::Struct; end"
57
+ const_get(klass_name).tap { |klass| klass.class_eval(&block) }
58
+ end
59
+ end
@@ -0,0 +1,43 @@
1
+ #
2
+ # Checks whether an unwrapped type is valid
3
+ #
4
+ module Dry::Initializer::Dispatchers::CheckType
5
+ extend self
6
+
7
+ def call(source:, type: nil, wrap: 0, **options)
8
+ check_if_callable! source, type
9
+ check_arity! source, type, wrap
10
+
11
+ { source: source, type: type, wrap: wrap, **options }
12
+ end
13
+
14
+ private
15
+
16
+ def check_if_callable!(source, type)
17
+ return if type.nil?
18
+ return if type.respond_to?(:call)
19
+
20
+ raise ArgumentError,
21
+ "The type of the argument '#{source}' should be callable"
22
+ end
23
+
24
+ def check_arity!(_source, type, wrap)
25
+ return if type.nil?
26
+ return if wrap.zero?
27
+ return if type.method(:call).arity.abs == 1
28
+
29
+ raise ArgumentError, <<~MESSAGE
30
+ The dry_intitializer supports wrapped types with one argument only.
31
+ You cannot use array types with element coercers having several arguments.
32
+
33
+ For example, this definitions are correct:
34
+ option :foo, [proc(&:to_s)]
35
+ option :bar, type: [[]]
36
+ option :baz, ->(a, b) { [a, b] }
37
+
38
+ While this is not:
39
+ option :foo, [->(a, b) { [a, b] }]
40
+ MESSAGE
41
+ end
42
+ # rubocop: enable Metrics/MethodLength
43
+ end