From a65030e62c5cda1c780885ac91e22c3e63892395 Mon Sep 17 00:00:00 2001 From: Jon Palmer <328224+jonspalmer@users.noreply.github.com> Date: Sat, 17 Jul 2021 17:00:08 -0400 Subject: [PATCH 1/4] Add attribute readers for unknown attributes * default behavior remains unchanged. Unknown params and options are ignored but stored in their own attr_reader * rest params and rest options names are configurable * rest params and rest options can be turned off for strict mode --- lib/dry/initializer.rb | 14 +++ lib/dry/initializer/builders/attribute.rb | 19 +-- lib/dry/initializer/builders/initializer.rb | 11 ++ lib/dry/initializer/builders/signature.rb | 25 +++- lib/dry/initializer/config.rb | 35 +++++- .../initializer/dispatchers/prepare_target.rb | 3 +- spec/attributes_spec.rb | 22 +++- spec/required_spec.rb | 31 +++++ spec/several_assignments_spec.rb | 10 +- spec/unknown_spec.rb | 109 ++++++++++++++++++ 10 files changed, 249 insertions(+), 30 deletions(-) create mode 100644 spec/required_spec.rb create mode 100644 spec/unknown_spec.rb diff --git a/lib/dry/initializer.rb b/lib/dry/initializer.rb index b5f7905..b9a9400 100644 --- a/lib/dry/initializer.rb +++ b/lib/dry/initializer.rb @@ -47,6 +47,20 @@ def option(name, type = nil, **opts, &block) self end + # Sets rest params name or turns off rest params of [#dry_initializer] + # @param [Symbol, nil] name + def rest_params(name) + dry_initializer.rest_params = name + self + end + + # Sets rest options name or turns off rest options of [#dry_initializer] + # @param [Symbol, nil] name + def rest_options(name) + dry_initializer.rest_options = name + self + end + private def inherited(klass) diff --git a/lib/dry/initializer/builders/attribute.rb b/lib/dry/initializer/builders/attribute.rb index dab4f8b..76bd39d 100644 --- a/lib/dry/initializer/builders/attribute.rb +++ b/lib/dry/initializer/builders/attribute.rb @@ -21,10 +21,9 @@ def initialize(definition) @source = definition.source @ivar = definition.ivar @null = definition.null ? 'Dry::Initializer::UNDEFINED' : 'nil' - @opts = '__dry_initializer_options__' @congif = '__dry_initializer_config__' @item = '__dry_initializer_definition__' - @val = @option ? '__dry_initializer_value__' : @source + @val = @source end # rubocop: enable Metrics/MethodLength @@ -32,28 +31,12 @@ def lines [ '', definition_line, - reader_line, default_line, coercion_line, assignment_line ] end - def reader_line - return unless @option - - @optional ? optional_reader : required_reader - end - - def optional_reader - "#{@val} = #{@opts}.fetch(:'#{@source}', #{@null})" - end - - def required_reader - "#{@val} = #{@opts}.fetch(:'#{@source}')" \ - " { raise KeyError, \"\#{self.class}: #{@definition} is required\" }" - end - def definition_line return unless @type || @default diff --git a/lib/dry/initializer/builders/initializer.rb b/lib/dry/initializer/builders/initializer.rb index a9aa9c2..358c2ea 100644 --- a/lib/dry/initializer/builders/initializer.rb +++ b/lib/dry/initializer/builders/initializer.rb @@ -25,6 +25,7 @@ def lines define_line, params_lines, options_lines, + unknown_lines, end_line ] end @@ -50,6 +51,16 @@ def options_lines .map { |line| ' ' << line } end + def unknown_lines + lines = [] + lines << "@#{@config.rest_params} = #{@config.rest_params}" if @config.rest_params + + if @config.rest_params && @definitions.any?(&:option) + lines << "@#{@config.rest_options} = #{@config.rest_options}" + end + lines.map { |line| ' ' << line } + end + def end_line 'end' end diff --git a/lib/dry/initializer/builders/signature.rb b/lib/dry/initializer/builders/signature.rb index 480e4cd..4eb0bb3 100644 --- a/lib/dry/initializer/builders/signature.rb +++ b/lib/dry/initializer/builders/signature.rb @@ -6,7 +6,14 @@ def self.[](config) end def call - [*required_params, *optional_params, '*', options].compact.join(', ') + [ + *required_params, + *optional_params, + rest_params, + *required_options, + *optional_options, + rest_options + ].compact.join(', ') end private @@ -25,8 +32,20 @@ def optional_params @config.params.select(&:optional).map { |rec| "#{rec.source} = #{@null}" } end - def options - '**__dry_initializer_options__' if @options + def rest_params + "*#{@config.rest_params}" if @config.rest_params + end + + def required_options + @config.options.reject(&:optional).map { |rec| "#{rec.source}:" } + end + + def optional_options + @config.options.select(&:optional).map { |rec| "#{rec.source}: #{@null}" } + end + + def rest_options + "**#{@config.rest_options}" if @options && @config.rest_options end end end diff --git a/lib/dry/initializer/config.rb b/lib/dry/initializer/config.rb index 49fa435..85a86b2 100644 --- a/lib/dry/initializer/config.rb +++ b/lib/dry/initializer/config.rb @@ -16,7 +16,7 @@ class Config # @return [Hash] # hash of attribute definitions with their source names - attr_reader :null, :extended_class, :parent, :definitions + attr_reader :null, :extended_class, :parent, :definitions, :rest_params, :rest_options # @!attribute [r] mixin # @return [Module] reference to the module to be included into class @@ -71,6 +71,24 @@ def option(name, type = nil, **opts, &block) add_definition(true, name, type, block, **opts) end + # Sets rest parameter name or turns rest params off + # @param [Symbol, nil] name + def rest_params=(name) + # remove the old attr_reader + mixin.class_eval(undef_method_code(@rest_params)) if @rest_params + @rest_params = name + finalize + end + + # Sets rest options name or turns rest options off + # @param [Symbol, nil] name + def rest_options=(name) + # remove the old attr_reader + mixin.class_eval(undef_method_code(@rest_options)) if @rest_options + @rest_options = name + finalize + end + # The hash of public attributes for an instance of the [#extended_class] # @param [Dry::Initializer::Instance] instance # @return [Hash] @@ -107,6 +125,7 @@ def finalize @definitions = final_definitions check_order_of_params mixin.class_eval(code) + mixin.class_eval(unknowns_code) children.each(&:finalize) self end @@ -115,7 +134,6 @@ def finalize # @return [String] def inch line = Builders::Signature[self] - line = line.gsub('__dry_initializer_options__', 'options') lines = ["@!method initialize(#{line})"] lines += ["Initializes an instance of #{extended_class}"] lines += definitions.values.map(&:inch) @@ -131,6 +149,8 @@ def initialize(extended_class = nil, null: UNDEFINED) @parent = sklass.dry_initializer if sklass.is_a? Dry::Initializer @null = null || parent&.null @definitions = {} + @rest_params = "__dry_initializer_unknown_params__" + @rest_options = "__dry_initializer_unknown_options__" finalize end @@ -161,6 +181,17 @@ def final_definitions end end + def unknowns_code + lines = [undef_method_code(rest_params), undef_method_code(rest_options)] + lines << "attr_reader :#{rest_params}" if rest_params + lines << "attr_reader :#{rest_options}" if rest_options + lines.join("\n") + end + + def undef_method_code(name) + "undef :#{name} if method_defined? :#{name}" + end + def check_type(previous, current) return current unless previous return current if previous.option == current.option diff --git a/lib/dry/initializer/dispatchers/prepare_target.rb b/lib/dry/initializer/dispatchers/prepare_target.rb index 99da1d4..112c75a 100644 --- a/lib/dry/initializer/dispatchers/prepare_target.rb +++ b/lib/dry/initializer/dispatchers/prepare_target.rb @@ -9,7 +9,8 @@ module Dry::Initializer::Dispatchers::PrepareTarget # List of variable names reserved by the gem RESERVED = %i[ - __dry_initializer_options__ + __dry_initializer_unkown_params__ + __dry_initializer_unkown_options__ __dry_initializer_config__ __dry_initializer_value__ __dry_initializer_definition__ diff --git a/spec/attributes_spec.rb b/spec/attributes_spec.rb index ee497db..3360b04 100644 --- a/spec/attributes_spec.rb +++ b/spec/attributes_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + describe Dry::Initializer, "dry_initializer.attributes" do subject { instance.class.dry_initializer.attributes(instance) } @@ -14,7 +16,15 @@ class Test::Foo let(:instance) { Test::Foo.new(:FOO) } it "collects coerced params with default values" do - expect(subject).to eq({ foo: "FOO", bar: 1 }) + expect(subject).to eq({foo: "FOO", bar: 1}) + end + + context "with unknown params" do + let(:instance) { Test::Foo.new(:FOO, :BAR, :BAZ, :FUTZ) } + + it "ignores extra params" do + expect(subject).to eq({foo: "FOO", bar: :BAR, baz: :BAZ}) + end end end @@ -32,7 +42,15 @@ class Test::Foo let(:instance) { Test::Foo.new(foo: :FOO, qux: :QUX) } it "collects coerced and renamed options with default values" do - expect(subject).to eq({ foo: :FOO, bar: 1, quxx: "QUX" }) + expect(subject).to eq({foo: :FOO, bar: 1, quxx: "QUX"}) + end + + context "with extra unknown options" do + let(:instance) { Test::Foo.new(foo: :FOO, qux: :QUX, futz: :FUTZ) } + + it "ignores extra options" do + expect(subject).to eq({foo: :FOO, bar: 1, quxx: "QUX"}) + end end end end diff --git a/spec/required_spec.rb b/spec/required_spec.rb new file mode 100644 index 0000000..b62dfda --- /dev/null +++ b/spec/required_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +describe "required param" do + before do + class Test::Foo + extend Dry::Initializer + + param :foo + param :bar, optional: true + end + end + + it "raise ArgumentError" do + expect { Test::Foo.new() }.to raise_exception(ArgumentError) + end +end + +describe "required option" do + before do + class Test::Foo + extend Dry::Initializer + + option :foo + option :bar, optional: true + end + end + + it "raise ArgumentError" do + expect { Test::Foo.new() }.to raise_exception(ArgumentError) + end +end diff --git a/spec/several_assignments_spec.rb b/spec/several_assignments_spec.rb index 6132fc1..260af5c 100644 --- a/spec/several_assignments_spec.rb +++ b/spec/several_assignments_spec.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + describe "attribute with several assignments" do before do class Test::Foo extend Dry::Initializer - option :bar, proc(&:to_s), optional: true - option :"some foo", as: :bar, optional: true + option :bar, proc(&:to_s), optional: true + option :some_foo, as: :bar, optional: true end end @@ -13,7 +15,7 @@ class Test::Foo it "is left undefined" do expect(subject.bar).to be_nil - expect(subject.instance_variable_get :@bar) + expect(subject.instance_variable_get(:@bar)) .to eq Dry::Initializer::UNDEFINED end end @@ -27,7 +29,7 @@ class Test::Foo end context "when renamed" do - subject { Test::Foo.new "some foo": :BAZ } + subject { Test::Foo.new some_foo: :BAZ } it "renames the attribute" do expect(subject.bar).to eq :BAZ diff --git a/spec/unknown_spec.rb b/spec/unknown_spec.rb new file mode 100644 index 0000000..e2500c2 --- /dev/null +++ b/spec/unknown_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +describe "unknow param" do + before do + class Test::Foo + extend Dry::Initializer + + param :foo + param :bar, optional: true + end + end + + it "suports unknown params" do + subject = Test::Foo.new(1, 2, 3, 4) + + expect(subject.__dry_initializer_unknown_params__).to eq([3, 4]) + end + + it "suports unknown params with last as hash" do + subject = Test::Foo.new(1, 2, 3, {fizz: 4}) + + expect(subject.__dry_initializer_unknown_params__).to eq([3, {fizz: 4}]) + end + + context "with custom rest params" do + before do + class Test::Bar + extend Dry::Initializer + + param :foo + param :bar, optional: true + rest_params :qux + end + end + + it "supports unknown options" do + subject = Test::Bar.new(1, 2, 3, 4) + + expect(subject.qux).to eq([3, 4]) + end + end + + context "without rest params" do + before do + class Test::Bar + extend Dry::Initializer + + param :foo + param :bar, optional: true + rest_params false + end + end + + it "raise an ArgumentError" do + expect { Test::Bar.new(1, 2, 3, 4) }.to raise_exception ArgumentError + end + end +end + +describe "unknown option" do + before do + class Test::Foo + extend Dry::Initializer + + option :foo + option :bar, optional: true + end + end + + it "supports unknown options" do + subject = Test::Foo.new(foo: 1, bar: 2, fizz: 3, buzz: 4) + + expect(subject.__dry_initializer_unknown_options__).to eq(fizz: 3, buzz: 4) + end + + context "with custom rest options" do + before do + class Test::Bar + extend Dry::Initializer + + option :foo + option :bar, optional: true + rest_options :kwargs + end + end + + it "supports unknown options" do + subject = Test::Bar.new(foo: 1, bar: 2, fizz: 3, buzz: 4) + + expect(subject.kwargs).to eq(fizz: 3, buzz: 4) + end + end + + context "with out rest options" do + before do + class Test::Bar + extend Dry::Initializer + + option :foo + option :bar, optional: true + rest_options false + end + end + + it "raise an ArgumentError" do + expect { Test::Bar.new(foo: 1, bar: 2, fizz: 3, buzz: 4) }.to raise_exception ArgumentError + end + end +end From af287bf098100c15e2b21f79efcdfe6bd733d8ca Mon Sep 17 00:00:00 2001 From: Jon Palmer <328224+jonspalmer@users.noreply.github.com> Date: Sat, 17 Jul 2021 17:32:35 -0400 Subject: [PATCH 2/4] Rubocop linting of changed lines --- lib/dry/initializer/builders/initializer.rb | 4 ++-- lib/dry/initializer/builders/signature.rb | 4 ++-- spec/required_spec.rb | 4 ++-- spec/unknown_spec.rb | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/dry/initializer/builders/initializer.rb b/lib/dry/initializer/builders/initializer.rb index 358c2ea..af296a8 100644 --- a/lib/dry/initializer/builders/initializer.rb +++ b/lib/dry/initializer/builders/initializer.rb @@ -55,10 +55,10 @@ def unknown_lines lines = [] lines << "@#{@config.rest_params} = #{@config.rest_params}" if @config.rest_params - if @config.rest_params && @definitions.any?(&:option) + if @config.rest_params && @definitions.any?(&:option) lines << "@#{@config.rest_options} = #{@config.rest_options}" end - lines.map { |line| ' ' << line } + lines.map { |line| " " << line } end def end_line diff --git a/lib/dry/initializer/builders/signature.rb b/lib/dry/initializer/builders/signature.rb index 4eb0bb3..d45ca69 100644 --- a/lib/dry/initializer/builders/signature.rb +++ b/lib/dry/initializer/builders/signature.rb @@ -8,12 +8,12 @@ def self.[](config) def call [ *required_params, - *optional_params, + *optional_params, rest_params, *required_options, *optional_options, rest_options - ].compact.join(', ') + ].compact.join(", ") end private diff --git a/spec/required_spec.rb b/spec/required_spec.rb index b62dfda..d03cb4f 100644 --- a/spec/required_spec.rb +++ b/spec/required_spec.rb @@ -11,7 +11,7 @@ class Test::Foo end it "raise ArgumentError" do - expect { Test::Foo.new() }.to raise_exception(ArgumentError) + expect { Test::Foo.new }.to raise_exception(ArgumentError) end end @@ -26,6 +26,6 @@ class Test::Foo end it "raise ArgumentError" do - expect { Test::Foo.new() }.to raise_exception(ArgumentError) + expect { Test::Foo.new }.to raise_exception(ArgumentError) end end diff --git a/spec/unknown_spec.rb b/spec/unknown_spec.rb index e2500c2..ccc9525 100644 --- a/spec/unknown_spec.rb +++ b/spec/unknown_spec.rb @@ -26,7 +26,7 @@ class Test::Foo before do class Test::Bar extend Dry::Initializer - + param :foo param :bar, optional: true rest_params :qux @@ -44,7 +44,7 @@ class Test::Bar before do class Test::Bar extend Dry::Initializer - + param :foo param :bar, optional: true rest_params false @@ -77,7 +77,7 @@ class Test::Foo before do class Test::Bar extend Dry::Initializer - + option :foo option :bar, optional: true rest_options :kwargs @@ -86,7 +86,7 @@ class Test::Bar it "supports unknown options" do subject = Test::Bar.new(foo: 1, bar: 2, fizz: 3, buzz: 4) - + expect(subject.kwargs).to eq(fizz: 3, buzz: 4) end end @@ -95,7 +95,7 @@ class Test::Bar before do class Test::Bar extend Dry::Initializer - + option :foo option :bar, optional: true rest_options false From 6a247b63fd77f9c82580fa650cbba4f2467de2a5 Mon Sep 17 00:00:00 2001 From: Jon Palmer <328224+jonspalmer@users.noreply.github.com> Date: Sat, 17 Jul 2021 21:20:09 -0400 Subject: [PATCH 3/4] Add docs --- .../tolerance-to-unknown-arguments.html.md | 44 +++++++++++++++++++ lib/dry/initializer/builders/initializer.rb | 4 +- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/docsite/source/tolerance-to-unknown-arguments.html.md b/docsite/source/tolerance-to-unknown-arguments.html.md index 23f303e..5a68b21 100644 --- a/docsite/source/tolerance-to-unknown-arguments.html.md +++ b/docsite/source/tolerance-to-unknown-arguments.html.md @@ -20,3 +20,47 @@ user.respond_to? :role # => false User.dry_initializer.attributes(user) # => {} ``` + +Unknown arguments are stored in a pair of properties + +```ruby +user.__dry_initializer_unknown_params__ # => ['Joe'] +user.__dry_initializer_unknown_options__ # => {role: 'admin'} +``` + +The names of the rest params and the rest options are configurable. + +```ruby +require 'dry-initializer' + +class User + extend Dry::Initializer + + rest_params :args + rest_options :kwargs +end + +user = User.new 'Joe', role: 'admin' +user.args # => ['Joe'] +user.kwargs # => {role: 'admin'} +user.respond_to? :__dry_initializer_unknown_params__ # => false +user.respond_to? :__dry_initializer_unknown_options__ # => false +``` + +Setting rest params or rest options to false results in strict argument checking. +Unknown arguments will result in ArgumentErrors. + +```ruby +require 'dry-initializer' + +class User + extend Dry::Initializer + + rest_params false + rest_options false +end + +user = User.new 'Joe' # => raises ArgumentError +user = User.new role: 'admin' # => raises ArgumentError + +``` \ No newline at end of file diff --git a/lib/dry/initializer/builders/initializer.rb b/lib/dry/initializer/builders/initializer.rb index af296a8..354d75d 100644 --- a/lib/dry/initializer/builders/initializer.rb +++ b/lib/dry/initializer/builders/initializer.rb @@ -25,7 +25,7 @@ def lines define_line, params_lines, options_lines, - unknown_lines, + rest_lines, end_line ] end @@ -51,7 +51,7 @@ def options_lines .map { |line| ' ' << line } end - def unknown_lines + def rest_lines lines = [] lines << "@#{@config.rest_params} = #{@config.rest_params}" if @config.rest_params From 3419a86417b59fb2ba1e9b1d14c7b84b6f215192 Mon Sep 17 00:00:00 2001 From: Jon Palmer <328224+jonspalmer@users.noreply.github.com> Date: Sun, 25 Jul 2021 22:07:57 -0400 Subject: [PATCH 4/4] revert string option spec changes --- spec/several_assignments_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/several_assignments_spec.rb b/spec/several_assignments_spec.rb index 260af5c..158e400 100644 --- a/spec/several_assignments_spec.rb +++ b/spec/several_assignments_spec.rb @@ -6,7 +6,7 @@ class Test::Foo extend Dry::Initializer option :bar, proc(&:to_s), optional: true - option :some_foo, as: :bar, optional: true + option "some_foo", as: :bar, optional: true end end @@ -29,7 +29,7 @@ class Test::Foo end context "when renamed" do - subject { Test::Foo.new some_foo: :BAZ } + subject { Test::Foo.new "some_foo": :BAZ } it "renames the attribute" do expect(subject.bar).to eq :BAZ