cypriss / mutations

Compose your business logic into commands that sanitize and validate input.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Mutations with forms in Rails (2.3)

6twenty opened this issue · comments

This isn't an issue or a question. This describes how I have integrated Mutations into form views within a Rails 2.3 app. This could be a discussion on how Mutations could evolve to have wider uses within Rails, or it could just be a reference for others looking to implement their own similar solution. This is a bit of an experiment, but comments & feedback are welcome!

This is what my controller actions look like:

def new
  @mutation = CreateMutationClass.new
end

def create
  @mutation = CreateMutationClass.new(params[:mutation])
  outcome = @mutation.run
  if outcome.success?
    flash[:notice] = "Success"
    redirect_to some_path
  else
    @errors = outcome.errors.message_list
    render :action => :new
  end
end

def edit
  @mutation = UpdateMutationClass.new(MyModel.find(params[:id]))
end

def update
  @mutation = UpdateMutationClass.new(MyModel.find(params[:id]), params[:mutation])
  outcome = @mutation.run
  if outcome.success?
    flash[:notice] = "Success"
    redirect_to some_path
  else
    @errors = outcome.errors.message_list
    render :action => :edit
  end
end

Note that on the mutation class I initially call new rather than run; this allows me to bind the Rails forms to the mutation rather than an ActiveRecord model. I am also passing in an ActiveRecord model as the first parameter on the edit, and update actions, which assigns attributes to the mutation straight from the model (more on that below).

In my views I then use form_for @mutation, :url => some_path (the :url is required here, although I suspect it would be possible to extend the mutation class to allow Rails to deduce the url).

My mutation classes are mostly unchanged, but with a few additions. Here's an example of the CreateMutationClass.

class CreateMutationClass < Mutations::Command
  include MutationExtensions::Base

  use :new_record_mocks
  use :model_name

  required do
    # ...
  end

  optional do
    # ...
  end

  def execute
    # ...
  end

end

The required & optional blocks, and the execute method, are all standard as per the mutations docs.

And the UpdateMutationClass:

class UpdateMutationClass < Mutations::Command
  include MutationExtensions::Base

  use :existing_record_mocks
  use :model_name
  use :base_model, :my_model

  map(:some_attr) { |model| model.method_to_determine_some_attr }
  map(:another_attr) { Date.today }

  required do
    # ...
  end

  optional do
    # ...
  end

  def execute
    # ...
  end

end

use :new_record_mocks and use :existing_record_mocks each set up the following methods: id, to_param, new_record? and _destroy. These allow Rails to render the form properly.

use :model_name simply changes the input field base name, defaulting to 'mutation'.

use :base_model allows linking the mutation to an ActiveRecord model. This redefines the new method to allow the first parameter to be an ActiveRecord model of the expected class. By default the mutation attributes will attempt to get their value by calling a method of the same name on the model, however using a base model also allows using the map method to handle non-matching attributes. Passing in multiple parameters still works the same way (subsequent hash parameters will override existing attributes).

And here's the MutationExtensions module:

module MutationExtensions
  module Base
    def self.included(base)
      base.class_eval do
        def self.use(*args)
          case args.shift
          when :model_name
            @model_name = ::ActiveSupport::ModelName.new(args.first || 'mutation')
          when :base_model
            @base_model = args.first
            self.send(:extend, BaseModel)
            self.send(:required) do
              model(*args)
            end
          when :new_record_mocks
            self.send(:include, Mock::NewRecord)
          when :existing_record_mocks
            self.send(:include, Mock::ExistingRecord)
          end
        end
      end
    end
  end

  # Allows linking the mutation class to a model, so that
  # new mutation instances can be instantiated by passing
  # in a model instance as the first parameter
  module BaseModel
    def base_model
      @base_model
    end

    def mappings
      @mappings ||= {}.with_indifferent_access
    end

    def map(attribute, &block)
      mappings[attribute] = block
    end

    def new(*args)
      if args.first.class.model_name.underscore.to_sym == @base_model
        record = args.shift

        inputs_keys = input_filters.required_inputs.keys | input_filters.optional_inputs.keys
        attrs = inputs_keys.inject({}) do |hash, key|
          if proc = mappings[key]
            hash[key] = proc.call(record)
          elsif record.respond_to?(key)
            hash[key] = record.send(key)
          end
          hash
        end

        attrs[@base_model] = record
        args.unshift(attrs)
      end

      super(*args)
    end

    def association_reflection(association_name, associated_class)
      @association_reflections ||= {}
      @association_reflections[association_name.to_sym] = associated_class
    end

    def reflect_on_association(association_name)
      OpenStruct.new(:klass => @association_reflections[association_name.to_sym])
    end
  end

  # Mocks new or existing records, so that `form_for` renders ok
  module Mock
    module AnyRecord
      def _destroy; nil; end
      def to_param; id ? id.to_s : id; end
    end

    module NewRecord
      include Mock::AnyRecord
      def new_record?; true; end
      def id; nil; end
    end

    module ExistingRecord
      include Mock::AnyRecord
      def new_record?; false; end

      def id
        if base_model = self.class.base_model
          self.send(base_model).try(:id) || 0
        else
          0
        end
      end
    end
  end
end

@cypriss, I'd be interested to hear your thoughts on moving towards wider Rails support so that mutations can more easily apply to UI apps as well as APIs. I'm aware that there are other similar gems out there which integrate with Rails forms, but I've so far enjoyed working with mutations the most :-)

Hello, I've been looking at this gem to use in a normal Rails app and your post is very helpful @6twenty. Because having mutations work with the Rails form helpers is pretty important.

Could you say how this design has improved your code? I assume your models don't have any validations.

Is testing easier? Is the code cleaner? Etc.

I'm one of those people who has somewhat rejected the "Rails Kool-Aid", but nonetheless I'm practical, and model validations is probably one of the parts of "the Rails way" I'm OK with.

And one can pull out of a lot of business logic into Plain Old Ruby Classes without necessarily using this gem.

@leavengood we implemented this as part of a refactor for a specific area in our app. We had fallen into the very traps described in @cypriss's post introducing Mutations: we had a fat model with several virtual attributes which were being set via form params and handled using ActiveRecord callbacks. This (as the post says) sucked. The callbacks lost almost all context and had to re-determine the current state of the model before handling the changes correctly.

Could you say how this design has improved your code? I assume your models don't have any validations.

The refactored code is wonderfully simple, which is a pleasure since the previous code was quite complex. We ended up with ~10 mutation classes each with a very specific role. This led to some code duplication and probably more lines of code overall, but the code is significantly cleaner and vastly more maintainable.

Our models still have the usual validations -- we deliberately tried to leave the models as un-touched as possible because the refactor only focussed on a specific area of logic. The only changes on the model were to remove the relevant virtual attributes and callbacks.

Is testing easier? Is the code cleaner? Etc.

As you can imagine testing mutation classes is extremely simple. In contrast to testing virtual attributes + callbacks, our test suite is much cleaner (and faster).

And one can pull out of a lot of business logic into Plain Old Ruby Classes without necessarily using this gem.

This entered my mind as well, but to be honest we likely would've ended up with something very similar to this gem anyway. No need to reinvent the wheel.

@6twenty Thanks for the feedback. We already started using mutations in our project and so far so good. We even have a few small fixes and improvements to do as pull requests.

I've been thinking that leaving basic validations on the models is fine and the use mutations for more broad-scoped work. It sounds like that is mostly what you have done, so that is good to know.