Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow developers to define #call with arguments for convenience #135

Closed
wants to merge 5 commits into from

Conversation

laserlemon
Copy link
Collaborator

If the call instance method accepts arguments, those arguments will be automatically assigned from the provided context, matching on name. This works for both positional and keyword arguments. If an argument is specified but no matching value is provided in the context, an ArgumentError is raised.

973

If the "call" instance method accepts arguments, those arguments will be
automatically assigned from the provided context, matching on name. This
works for both positional and keyword arguments. If an argument is
specified but no matching value is provided in the context, an
ArgumentError is raised.
Required keyword arguments appeared in Ruby 2.1, after the introduction
of keyword arguments in Ruby 2.0.
This also improves performance of the Interactor#arguments_for_call
method by not duplicating the table of data held internally by the
context. As a happy side effect, this adds the
Interactor::Context#include? method which may be helpful for developers.
…by not interacting directly with OpenStruct#modifiable.
@laserlemon
Copy link
Collaborator Author

@hedgesky Would you mind taking a look at this? Thank you!

@hedgesky
Copy link
Contributor

Yeah, I'm watching the repo 😄
Feels a bit like magic; however, underlying code is simple and straightforward, great!
Excellent job! 💪

@@ -121,7 +121,7 @@ def failure?
#
# Raises Interactor::Failure initialized with the Interactor::Context.
def fail!(context = {})
context.each { |key, value| modifiable[key.to_sym] = value }
context.each { |key, value| self[key] = value }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

# based on their names.
#
# Returns an Array of arguments to be applied as an argument list.
def arguments_for_call # rubocop:disable Metrics/MethodLength
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really cool implementation!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'll end up backing out the positional argument support. It feels a little too magical in hindsight. Plus, I think there's a bug that I haven't tested for yet that could misplace positional arguments. The case I'm wondering about is:

class MyInteractor
  include Interactor

  def call(foo, hello = "world")
    context.output = [foo, hello]
  end
end

result = MyInteractor.call(hello: "Anton")
result.output # This should raise an ArgumentError, but might be ["Anton", "world"]

Copy link
Contributor

@hedgesky hedgesky Apr 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only thing that bothers me is that supporting only keywords arguments may be confusing. #call will not act like a standard Ruby method, but will have its own rules instead. There would be additional lines in Readme, things to keep in memory, things to explain to a newcomer into a project-which-uses-interactors, etc.

I thought about alternative syntax:

class MyInteractor
  include Interactor

  required_arguments :user, :order
  optional_arguments :email, send_notification: true

  def call
  end
end

On the one hand, keyword arguments do the same and in more expressive way and they are already familiar.
On the other hand, this familiarity may become a bad thing in our case. E.g, if I can write:

def call(hello:)
end

MyInteractor.call(hello: "Steve")

why couldn't I write

def call(name)
end
MyInteractor.call("Steve")

? And here magic falls apart.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed positional argument support. I think it just muddied the waters and could be achieved just as well with keyword arguments.

As for the last two code examples in your comment above, I don't feel like the arguments accepted by the call class method needs to necessarily agree 100% with the arguments accepted by the call instance method, but scrapping positional argument support certainly does make it more consistent between the two, which I like. What are your thoughts, @hedgesky?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I'd rather raise an exception for positional arguments. It would help avoiding such situations (just checked this in call-with-keywords branch):

class A
  include Interactor
  def call(foo, bar: "")
    puts "Foo is #{foo}"
    puts "Bar is #{bar}"
  end
end

A.call(bar: 1)

# Foo is {:bar=>1}
# Bar is 

Also this would draw a clear line between approaches we support and ones we don't.
@laserlemon, I'm open to discussion.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, great catch! Maybe when we include Interactor, we can check the call instance method's argument signature to make sure it's either all keywords or nothing at all. That way we can raise an error at load time rather than at run time. Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, aborting execution on load would be perfect. But I guess it's possible only if we force users to include Interactor after defining call, which is inconvenient:

module M
  def self.included(base)
    print "From #{base.name}: "
    puts base.instance_methods.grep(/call/).inspect
  end
end

class A
  include M
  def call(foo)
  end
end

class B
  def call(foo)
  end
  include M
end

# From A: []
# From B: [:call]

However, with runtime approach, method with wrong signature will raise an error at its very first run. I hope even those who don't write automated tests check their code manually at least once before deploying to production 😃

@laserlemon laserlemon added the WIP label Apr 1, 2017
There were edge cases that weren't yet covered by the specs that would
fail for combinations of positional and keyword arguments depending on
what's available in the context. For example:

    class MyInteractor
      include Interactor

      def call(a, b: "bears")
        context.output = [a, b]
      end
    end

    MyInteractor.call(b: "beets").output # => [{ b: "beets" }, "bears"]

Plus, this simplifies the interface by giving the developer one choice
rather than multiple competing choices that achieve the same thing.
@laserlemon laserlemon removed the WIP label Apr 28, 2017
@@ -17,7 +17,7 @@ Gem::Specification.new do |spec|
spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
spec.test_files = spec.files.grep(/^spec/)

spec.required_ruby_version = ">= 2.0"
spec.required_ruby_version = ">= 2.1"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A big thank-you for maintaining this value.

positional_arguments = []
keyword_arguments = {}

method(:call).parameters.each do |(type, name)|

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think should be here: self.class.instance_method(:call) instead of method(:call), because for some reason method(:call) returns the parameters from the class method "call", and not from the instance method "call" . I know this because i actually tried this and the only way i could make it work was to use self.class.instance_method .
Or maybe i am missing something.

@elsurudo
Copy link

@laserlemon This seems like a nice improvement, but an issue yet to be fixed as per @bogdanRada's comment?

@keithmattix
Copy link

Any updates on this?

@edisonywh
Copy link

Pinging it again. Any updates on this?

@kunalbhagawati
Copy link

Pinging it again, again. :)
Any updates on this PR?

@nitsujri
Copy link

nitsujri commented Aug 1, 2019

+1 for Custom DSL instead of positional arguments or even keyword-only .call.

.call is special and if it operates similar to a normal ruby function but not exactly like it, it will create lots of headaches.

#140 for reference.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants