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

Avoid #steps boilerplate by prepending on #call #9

Closed
wants to merge 1 commit into from

Conversation

waiting-for-dev
Copy link
Member

@waiting-for-dev waiting-for-dev commented Oct 5, 2023

We get rid of the necessity to wrap the operations' flow with the #steps's block by prepending a module that decorates the #call method.

If before we had to do:

class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end

Now we can do:

class CreateUser < Dry::Operation
  def call(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end

We want to provide that as the default behavior to improve the ergonomics of the library. However, we also want to provide a way to customize or opt out of this magic behavior. Users can inherit from, e.g., Dry::Operation[prepend: :run] to decorate the #run method (multiple methods are allowed by passing an array), or Dry::Operation[prepend: false] to disable the behavior:

class CreateUser < Dry::Operation[prepend: :run]
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
class CreateUser < Dry::Operation[prepend: :false]
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end

Notice that as a consequence of this change, we're "polluting" the ancestry chain of the descendant classes with the prepender logic:

Class.new(Dry::Operation).ancestors
# [#<Module:0x00007f8e523c1698>, <- Actual prepender
#  #<Class:0x00007f8e523c1918>, <- Descendant
#  #<Dry::Operation::Prepender:0x00007f8e523c17d8>, <- Stateful module keeping the prepender options
#  Dry::Operation,
#  Dry::Operation::Mixin,
# ...]

When creating a descendant from a descendant, the prepender logic is duplicated to override the decorated method from the nested descendant (which goes before the first prepender in the ancestry):

Class.new(Class.new(Dry::Operation)).ancestors
# [#<Module:0x00007f8e52331408>, <- Actual prepender
#  #<Class:0x00007f8e52331688>, <- Nested descendant
#  #<Dry::Operation::Prepender:0x00007f8e52331548>, <- Stateful module keeping the prepender options
#  #<Module:0x00007f8e52331868>, <- Previous prepender
#  #<Class:0x00007f8e52331ae8>, <- Descendant
#  #<Dry::Operation::Prepender:0x00007f8e523319a8>, <- Previous stateful module keeping the prepender options
#  Dry::Operation,
#  Dry::Operation::Mixin,
#  ...]

Also, notice that Dry::Operation[] will return an anonymous Class so it can be inherited from, so Dry::Operation won't appear in the ancestry chain:

Class.new(Dry::Operation[prepend: false]).ancestors
# [#<Class:0x00007f8e5233eec8>, <- Descendant
#  #<Class:0x00007f8e5233f328>, <- Anonymous class returned by Dry::Operation[]
#  Dry::Operation::Mixin,
#  ...]

To accomodate the above, the steps logic has been moved to a Dry::Operation::Mixin module so it can be reused both by descendants from Dry::Operation and Dry::Operation[].

It's worth it to highlight all that complexity, although it should be transparent to the user. As said, it's a trade-off we consider worth it to improve developer experience.

We get rid of the necessity to wrap the operations' flow with the
`#steps`'s block by prepending a module that decorates the `#call`
method.

If before we had to do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

Now we can do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

We want to provide that as the default behavior to improve the
ergonomics of the library. However, we also want to provide a way to
customize or opt out of this magic behavior. Users can inherit from,
e.g., `Dry::Operation[prepend: :run]` to decorate the `#run` method
(multiple methods are allowed by passing an array), or
`Dry::Operation[prepend: false]` to disable the behavior:

```ruby
class CreateUser < Dry::Operation[prepend: :run]
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

```ruby
class CreateUser < Dry::Operation[prepend: :false]
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

Notice that as a consequence of this change, we're "polluting" the
ancestry chain of the descendant classes with the prepender logic:

```ruby
Class.new(Dry::Operation).ancestors
 # [#<Module:0x00007f8e523c1698>, <- Actual prepender
 #  #<Class:0x00007f8e523c1918>, <- Descendant
 #  #<Dry::Operation::Prepender:0x00007f8e523c17d8>, <- Stateful module keeping the prepender options
 #  Dry::Operation,
 #  Dry::Operation::Mixin,
 # ...]
```

When creating a descendant from a descendant, the prepender logic is
duplicated to override the decorated method from the nested descendant
(which goes before the first prepender in the ancestry):

```ruby
Class.new(Class.new(Dry::Operation)).ancestors
 # [#<Module:0x00007f8e52331408>, <- Actual prepender
 #  #<Class:0x00007f8e52331688>, <- Nested descendant
 #  #<Dry::Operation::Prepender:0x00007f8e52331548>, <- Stateful module keeping the prepender options
 #  #<Module:0x00007f8e52331868>, <- Previous prepender
 #  #<Class:0x00007f8e52331ae8>, <- Descendant
 #  #<Dry::Operation::Prepender:0x00007f8e523319a8>, <- Previous stateful module keeping the prepender options
 #  Dry::Operation,
 #  Dry::Operation::Mixin,
 #  ...]
```

Also, notice that `Dry::Operation[]` will return an anonymous `Class` so
it can be inherited from, so `Dry::Operation` won't appear in the
ancestry chain:

```ruby
Class.new(Dry::Operation[prepend: false]).ancestors
 # [#<Class:0x00007f8e5233eec8>, <- Descendant
 #  #<Class:0x00007f8e5233f328>, <- Anonymous class returned by Dry::Operation[]
 #  Dry::Operation::Mixin,
 #  ...]
```

To accomodate the above, the steps logic has been moved to a
`Dry::Operation::Mixin` module so it can be reused both by descendants
from `Dry::Operation` and `Dry::Operation[]`.

It's worth it to highlight all that complexity, although it should be
transparent to the user. As said, it's a trade-off we consider worth it
to improve developer experience.
@timriley
Copy link
Member

timriley commented Oct 9, 2023

Thanks for this, @waiting-for-dev! I hope to get onto this sometime in the coming week, but I did want to give some early feedback on one aspect: I'd rather we avoid the whole dynamic subclassing pattern here.

class MyClass < ParentClass[args, go, here] is clever (and yes, I know rom-repository does it), but ultimately I think it's not a very ergonomic developer experience (as soon as you need more than one or two args, or you want a descriptive keyword argument name, it gets very lengthy and unwieldy), and it completely conflicts with tools like Sorbet, which will want statically resolvable constants for superclass names.

If you want to look for an analog within the dry-rb ecosystem for API inspiration, perhaps dry-struct is a good one, in that it offers a couple of class-level methods for controlling its overall behaviour.

Keen to hear your thoughts :)

@waiting-for-dev
Copy link
Member Author

Thanks for your feedback, @timriley 🙌

I get your points and they make a lot of sense. However, we're in a tricky situation here, as I see no way to use dry-operation through inheritance + defaulting to decorate #call + still allow people to configure or opt out of the prepended behavior. That's because once you do class MyClass < Dry::Operation, the .inherited hook will be called, and once a module has been prepended there's no way to unprepend it. We can maybe find a middle-ground solution somewhere:

  1. Keep using the dynamic subclassing pattern only for this specific behavior. I.e., use DSL methods to configure things like the monad type or ORM extensions. That would be bad for Sorbet.
  2. Default to not prepending the module, and instead use a DSL method for that. Bad for ergonomics.
  3. Use Dry::Operation through the anonymous module pattern, still defaulting to prepend the module. That would affect expectations on Hanami, where different layers work through inheritance. However, we could easily work around that by creating a Hanami::Operation class in the framework that would just include the Dry::Operation module.

I think 3 is the best solution, but I'm very interested in hearing your thoughts 🙂

@timriley
Copy link
Member

@waiting-for-dev All good points, thanks for explaining them :)

I've been thinking about this, and I think there might still be some options here for us. So far, we've been thinking of .inherited as our one and only shot for prepending the behaviour we want. But I think there's a couple more we could consider:

  1. the moment at which .new is called,
  2. or the .method_added hook.

Both of these would occur after the user has had the chance to call some class-level method for customising the class' behaviour.

I think .new is probably too late, but .method_added feels almost perfect. By the time the class is already opened, and the user has called any class-level methods to customise that behaviour, we will know exactly which method needs prepending. So we can watch for that method via .method_added, and when that is added, we can prepend the module and our job's done. .method_added can be a no op in all other cases.

What do you think?

@timriley
Copy link
Member

We could even have .method_added undef itself after it finds the relevant method (which will usually the first user-defined method in typical usage).

@waiting-for-dev
Copy link
Member Author

Those are great calls, @timriley!

I think .new is probably too late

I think that wouldn't be too late, as we can always use Object#extend to modify the singleton for the instance. It's something I tried while working on kwork and it did the trick. However, I didn't want to go down that path because, unless Ruby has changed and I didn't notice, that would invalidate the method cache and therefore could have severe performance issues.

.method_added feels almost perfect

I agree! I'll give it a try and get back with my findings 🙂

@waiting-for-dev
Copy link
Member Author

@timriley, one thing that could be very surprising is having the class-level method ignored when called after the method definition. E:g.:

class CreateUser < Dry::Operation
  def run
    # ...
  end
  wrap :run
end

We don't have that problem either with anonymous inheritance or mixin inclusion. IMO that's a big trade-off, as shipping a DSL that depends on global (load order) state is quite brittle. What are your thoughts on that?

@timriley
Copy link
Member

timriley commented Oct 13, 2023

@waiting-for-dev I'm not personally concerned about this. We can document this limitation. And most people would never run into this anyway, since "the top of the class" is where class-level configuration tends to go anyway.

We could also possibly combine this with a check at the time of the first call to .new that could determine whether the expected module has been prepended, and return an error or print a warning or something.

@waiting-for-dev
Copy link
Member Author

waiting-for-dev commented Oct 13, 2023

@waiting-for-dev I'm not personally concerned about this. We can document this limitation. And most people would never run into this anyway, since "the top of the class" is where class-level configuration tends to go anyway.

Just came to my mind we can also check Object.instance_method(:call) and avoid .method_added if already defined.

waiting-for-dev added a commit that referenced this pull request Oct 20, 2023
We get rid of the necessity to wrap the operations' flow with the
`#steps`'s block by prepending a module that decorates the `#call`
method.

If before we had to do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

Now we can do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

We want to provide that as the default behavior to improve the
ergonomics of the library. However, we also want to provide a way to
customize or opt out of this magic behavior.

After discarding dynamic inheritance because of DX concerns (see
#9), we opt for implementing
a couple of class-level methods to tweak the defaults.

`.operate_on` allows to customize the method to decorate. E.g., this is
how we decorate `#run` instead of `#call`:

```ruby
class CreateUser < Dry::Operation
  operate_on :run # Several methods can be passed as arguments

  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

On the other hand, `.skip_prepending` allows to opt out of the default
`#call` decoration:

```ruby
class CreateUser < Dry::Operation
  skip_prepending

  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

To have `#call` decorated by default but still be something
configurable, we need to rely on Ruby's `.method_added` hook. Notice
that for any other method specified by `.operate_on` we could just
include the prepender module and avoid going through the hook. However,
we opt for still using the hook to have a single way of doing things.

Both `.operate_on` and `.skip_prepending` tweaks are inherited by
subclasses, so it's possible to do something like:

```ruby
class BaseOperation < Dry::Operation
  operate_on :run
end

class CreateUser < BaseOperation
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

Both methods raise an exception when called after any method has been
prepended. This is to avoid misunderstandings like trying to skip
prepending after the `.method_added` hook has been triggered:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end
  skip_prepending # At this point, `#call` would have already been prepended

  # ...
end
```

Similarly, `.operate_on` raises an exception when called after the
method has already been defined.

```ruby
class CreateUser < Dry::Operation
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end
  operate_on :run # At this point, `.method_added` won't be called for `#run`

  # ...
end
```

Those checks are reset when a subclass is defined to allow for
redefinitions or changes in the configuration.
@waiting-for-dev
Copy link
Member Author

@timriley, please, see #11 for the implementation via class-level methods. Closing this one.

@waiting-for-dev waiting-for-dev deleted the waiting-for-dev/prepend_call branch October 20, 2023 09:58
waiting-for-dev added a commit that referenced this pull request Oct 24, 2023
We get rid of the necessity to wrap the operations' flow with the
`#steps`'s block by prepending a module that decorates the `#call`
method.

If before we had to do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

Now we can do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

We want to provide that as the default behavior to improve the
ergonomics of the library. However, we also want to provide a way to
customize or opt out of this magic behavior.

After discarding dynamic inheritance because of DX concerns (see
#9), we opt for implementing
a couple of class-level methods to tweak the defaults.

`.operate_on` allows to customize the method to decorate. E.g., this is
how we decorate `#run` instead of `#call`:

```ruby
class CreateUser < Dry::Operation
  operate_on :run # Several methods can be passed as arguments

  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

On the other hand, `.skip_prepending` allows to opt out of the default
`#call` decoration:

```ruby
class CreateUser < Dry::Operation
  skip_prepending

  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

To have `#call` decorated by default but still be something
configurable, we need to rely on Ruby's `.method_added` hook. Notice
that for any other method specified by `.operate_on` we could just
include the prepender module and avoid going through the hook. However,
we opt for still using the hook to have a single way of doing things.

Both `.operate_on` and `.skip_prepending` tweaks are inherited by
subclasses, so it's possible to do something like:

```ruby
class BaseOperation < Dry::Operation
  operate_on :run
end

class CreateUser < BaseOperation
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

Both methods raise an exception when called after any method has been
prepended. This is to avoid misunderstandings like trying to skip
prepending after the `.method_added` hook has been triggered:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end
  skip_prepending # At this point, `#call` would have already been prepended

  # ...
end
```

Similarly, `.operate_on` raises an exception when called after the
method has already been defined.

```ruby
class CreateUser < Dry::Operation
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end
  operate_on :run # At this point, `.method_added` won't be called for `#run`

  # ...
end
```

Those checks are reset when a subclass is defined to allow for
redefinitions or changes in the configuration.
waiting-for-dev added a commit that referenced this pull request Oct 25, 2023
We get rid of the necessity to wrap the operations' flow with the
`#steps`'s block by prepending a module that decorates the `#call`
method.

If before we had to do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

Now we can do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

We want to provide that as the default behavior to improve the
ergonomics of the library. However, we also want to provide a way to
customize or opt out of this magic behavior.

After discarding dynamic inheritance because of DX concerns (see
#9), we opt for implementing
a couple of class-level methods to tweak the defaults.

`.operate_on` allows to customize the method to decorate. E.g., this is
how we decorate `#run` instead of `#call`:

```ruby
class CreateUser < Dry::Operation
  operate_on :run # Several methods can be passed as arguments

  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

On the other hand, `.skip_prepending` allows to opt out of the default
`#call` decoration:

```ruby
class CreateUser < Dry::Operation
  skip_prepending

  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

To have `#call` decorated by default but still be something
configurable, we need to rely on Ruby's `.method_added` hook. Notice
that for any other method specified by `.operate_on` we could just
include the prepender module and avoid going through the hook. However,
we opt for still using the hook to have a single way of doing things.

Both `.operate_on` and `.skip_prepending` tweaks are inherited by
subclasses, so it's possible to do something like:

```ruby
class BaseOperation < Dry::Operation
  operate_on :run
end

class CreateUser < BaseOperation
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

Both methods raise an exception when called after any method has been
prepended. This is to avoid misunderstandings like trying to skip
prepending after the `.method_added` hook has been triggered:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end
  skip_prepending # At this point, `#call` would have already been prepended

  # ...
end
```

Similarly, `.operate_on` raises an exception when called after the
method has already been defined.

```ruby
class CreateUser < Dry::Operation
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end
  operate_on :run # At this point, `.method_added` won't be called for `#run`

  # ...
end
```

Those checks are reset when a subclass is defined to allow for
redefinitions or changes in the configuration.
waiting-for-dev added a commit that referenced this pull request Oct 26, 2023
We get rid of the necessity to wrap the operations' flow with the
`#steps`'s block by prepending a module that decorates the `#call`
method.

If before we had to do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

Now we can do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

We want to provide that as the default behavior to improve the
ergonomics of the library. However, we also want to provide a way to
customize or opt out of this magic behavior.

After discarding dynamic inheritance because of DX concerns (see
#9), we opt for implementing
a couple of class-level methods to tweak the defaults.

`.operate_on` allows to customize the method to decorate. E.g., this is
how we decorate `#run` instead of `#call`:

```ruby
class CreateUser < Dry::Operation
  operate_on :run # Several methods can be passed as arguments

  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

On the other hand, `.skip_prepending` allows to opt out of the default
`#call` decoration:

```ruby
class CreateUser < Dry::Operation
  skip_prepending

  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

To have `#call` decorated by default but still be something
configurable, we need to rely on Ruby's `.method_added` hook. Notice
that for any other method specified by `.operate_on` we could just
include the prepender module and avoid going through the hook. However,
we opt for still using the hook to have a single way of doing things.

Both `.operate_on` and `.skip_prepending` tweaks are inherited by
subclasses, so it's possible to do something like:

```ruby
class BaseOperation < Dry::Operation
  operate_on :run
end

class CreateUser < BaseOperation
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

Both methods raise an exception when called after any method has been
prepended. This is to avoid misunderstandings like trying to skip
prepending after the `.method_added` hook has been triggered:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end
  skip_prepending # At this point, `#call` would have already been prepended

  # ...
end
```

Similarly, `.operate_on` raises an exception when called after the
method has already been defined.

```ruby
class CreateUser < Dry::Operation
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end
  operate_on :run # At this point, `.method_added` won't be called for `#run`

  # ...
end
```

Those checks are reset when a subclass is defined to allow for
redefinitions or changes in the configuration.
waiting-for-dev added a commit that referenced this pull request Oct 30, 2023
We get rid of the necessity to wrap the operations' flow with the
`#steps`'s block by prepending a module that decorates the `#call`
method.

If before we had to do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

Now we can do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

We want to provide that as the default behavior to improve the
ergonomics of the library. However, we also want to provide a way to
customize or opt out of this magic behavior.

After discarding dynamic inheritance because of DX concerns (see
#9), we opt for implementing
a couple of class-level methods to tweak the defaults.

`.operate_on` allows to customize the method to decorate. E.g., this is
how we decorate `#run` instead of `#call`:

```ruby
class CreateUser < Dry::Operation
  operate_on :run # Several methods can be passed as arguments

  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

On the other hand, `.skip_prepending` allows to opt out of the default
`#call` decoration:

```ruby
class CreateUser < Dry::Operation
  skip_prepending

  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

To have `#call` decorated by default but still be something
configurable, we need to rely on Ruby's `.method_added` hook. Notice
that for any other method specified by `.operate_on` we could just
include the prepender module and avoid going through the hook. However,
we opt for still using the hook to have a single way of doing things.

Both `.operate_on` and `.skip_prepending` tweaks are inherited by
subclasses, so it's possible to do something like:

```ruby
class BaseOperation < Dry::Operation
  operate_on :run
end

class CreateUser < BaseOperation
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

Both methods raise an exception when called after any method has been
prepended. This is to avoid misunderstandings like trying to skip
prepending after the `.method_added` hook has been triggered:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end
  skip_prepending # At this point, `#call` would have already been prepended

  # ...
end
```

Similarly, `.operate_on` raises an exception when called after the
method has already been defined.

```ruby
class CreateUser < Dry::Operation
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end
  operate_on :run # At this point, `.method_added` won't be called for `#run`

  # ...
end
```

Those checks are reset when a subclass is defined to allow for
redefinitions or changes in the configuration.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants