dry-initializer 3.0.1

Sign up to get free protection for your applications and to get access to all the features.
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