2

A pool has many addresses. Want to create multiple address records based on a submitted range.

I have this logic in my addresses_controller:

  def create
    @pool = Pool.find(params[:pool_id])

    unless address_params[:ipv4_range_start].blank? || address_params[:ipv4_range_stop].blank?
      (address_params[:ipv4_range_start]..address_params[:ipv4_range_stop]).each do |octet|
        params[:address][:ipv4_octet3] = octet
        @address =  @pool.addresses.build(address_params)
        if !@address.save
           render 'new'
        end
      end
      redirect_to pool_path(@pool), notice: "Address range created."

    else #something was missing
      @address = @pool.addresses.build(address_params)
      @address.errors.add_on_blank(:ipv4_range_start)
      @address.errors.add_on_blank(:ipv4_range_stop)
      render 'new'
    end
  end

Wondering how I might move this into the Address model? Seems like too much for the controller but I can't figure out how to iterate through the submitted range and build and save each address from the Address model itself.

Thanks for any suggestions!

Jim

jacoulter
  • 730
  • 1
  • 9
  • 19
  • What's the purpose of this line? `params[:address][:ipv4_octet3] = octet` Since it's inside the loop its final value will just be the value of `octet` in the last iteration. Is that the intended behavior? – Jordan Running Jan 27 '15 at 21:44
  • It would be useful if you could edit your answer to include the expected contents of `params` and `address_params`, and the attributes of the Address objects that should be saved. – Jordan Running Jan 27 '15 at 21:47
  • `address_params` contains the allowed parameters `:ipv4_range_start/stop, :ipv4_octet0, :ipv4_octet1, and :ipv4_octet2`. The `:ipv4_octet3` value is obtained from the range, but since it isn't supplied by the form I had to insert it manually via the `params[:address][:ipv4_octet3] = octet` line. The `ipv4_range_start/stop` values are virtual attributes and not saved. Hope this helps. – jacoulter Jan 27 '15 at 22:04

1 Answers1

4

I think your intuition is correct that we can move a lot of this into the model.

Disclaimer: None of this code is tested; copying it and pasting it directly into your code will probably end in tears.

So there are two parts of your logic we need to deal with. The first is making sure :ipv4_range_start and _stop are provided. For that we can use a validation. Since it seems like you don't want those attributes to be required for all Addresses, we can use the :on option to supply a validation context. (More info on contexts here.):

# Address model
validates_presence_of :ipv4_range_start, :ipv4_range_stop,
                      on: :require_range

That on: :require_range part means this validation won't usually run—it'll only run when we tell ActiveRecord to use the :require_range context.

Now we can do this in the controller:

# AddressesController
def create
  @pool = Pool.find(params[:pool_id])

  @address = @pool.addresses.build(address_params)
  if @address.invalid?(:require_range)
    render 'new' and return
  end

  # ...
end

This accomplishes the same thing as the code in your else block, but the real logic is in the model, and Rails populates the errors object for us.

Now that we have that out of the way, we can deal with creating the objects. For that we can write a class method in Address. The great thing about class methods in Rails models is that they're automatically available on association collections, so for example if we define an Address.foo class method, we get @pool.addresses.foo for free. Here's a class method that will create an array of Addresses:

# Address model
def self.create_from_range!(attrs)
  start = attrs.fetch(:ipv4_range_start)
  stop = attrs.fetch(:ipv4_range_stop)

  self.transaction do
    (start..stop).map do |octet|
      self.create!(attrs.merge ipv4_octet3: octet)
    end
  end
end

This is pretty much the same as your else block, just a little cleaner. We do self.create! in a transaction, so that if any of the create!s fails they'll all be rolled back. We do that inside a map block instead of each so that, assuming no error occurs, the method will return an array of the created objects.

Now we just have to use it in our controller:

def create
  # Our code from before
  @pool = Pool.find(params[:pool_id])

  @address = @pool.addresses.build(address_params)
  if @address.invalid?(:require_range)
    render 'new' and return
  end

  # The new code
  begin
    @pool.addresses.create_from_range!(address_params)
  rescue ActiveRecord::ActiveRecordError
    flash[:error] = "Address range not created!"
    render 'new' and return
  end

  redirect_to @pool, notice: "Address range created."
end

As you can see, we used @pool.addresses.create_from_range!, so the association (Address#pool_id) will be filled in for us. If we wanted we could assign the returned array to an instance variable and show the created records in the view.

That should be about all you need.

P.S. One thing worth noting is that Ruby has a great built-in IPAddr class, and because an IP address is just a number (e.g. 3338456716 is the decimal form of 198.252.206.140) it may serve you to store each IP address as a single 4-byte integer instead of four separate columns. Most databases have useful built-in functions for dealing with IP addresses (PostgreSQL actually has a built-in inet column type for this) as well. It really depends on your use case, however, and at this point such an optimization may very well be premature. Cheers!

Jordan Running
  • 102,619
  • 17
  • 182
  • 182
  • Thank you very much for such a complete answer! Not only does this solve the problem but I've learned several new and useful things about Rails. Very generous and kind of you Jordan! – jacoulter Jan 28 '15 at 14:54
  • 1
    Glad it helped! P.S. I made one small change, which was to remove `attrs = attrs.dup` from the `Address.create_from_range!`. It's not necessary because the code never modifies `attrs` (`merge` makes a copy of the Hash before performing the merge; the original Hash is unchanged). – Jordan Running Jan 28 '15 at 17:10
  • 1
    I also swapped `unless @address.valid?(...)` for `if @address.invalid?(...)` just because it reads a bit more easily. – Jordan Running Jan 28 '15 at 17:18
  • @Jordan, Just a small suggestion. Pool.find(params[:pool_id]) here if we use find method then in case the object is not present then this line is going to throw ActiveRecord::RecordNotFound. Rather, we can use Pool.where(id: params[:pool_id]. In this case if record is not found then this is going to return nil instead of throwing exception. please share your thoughts on this :) – Ajay Jan 28 '15 at 18:22
  • @Ajay If `@pool` is `nil` then the very next line will raise a NoMethodError on `@pool.addresses`, so either way we get an exception. You're right that this should be handled some way, but the appropriate way depends on the behavior OP wants. Maybe an Address without a `pool_id` is still valid (e.g. there may be another view for creating Addresses independent from Pools), in which case logic will need to be added to account for that. – Jordan Running Jan 28 '15 at 18:30