Profile faxon

I build websites.

Edit Rails ActiveRecord JSON attributes in html forms

I recently ran into a situation where I wanted to store some user editable data in a JSON hash on an ActiveRecord model. The application is storing information about wine, which can have many different varietals. A wine can be made up of many different varietals, and each varietal is a percentage of the final wine. I'll also be storing the name and percentage of the varietal on each wine.

ActiveRecord has had some nice extensions for PostgreSQL JSON storage for a while, but most of the example out there only show editing the fields via the Rails console. I want to present how to use a JSON store in place of a has many relationship and accepts_nested_attributes_for that works with ActionView. The reason I wanted to use the JSON store in place of accepts_nested_attributes_for is that every time the wine is presented I also need information out of the JSON store. Using the JSON store means I won't need to use an ActiveRecord include to load the associated varietals everywhere.

In this post I'll show how to setup the JSON store, make it editable in a form by creating an inner class on Wine, and add some validation to the Wine model that depends on the varietal data stored in the JSON.

Creating the Wine model

The Wine model migration looks like this.

class CreateWines < ActiveRecord::Migration
  def change
    create_table :wines do |t|
      t.string  :producer
      t.integer :vintage
      t.json    :varietals, default: '[]'
      t.string  :designation

      t.timestamps null: false
    end
  end
end

We can create a record via the console.

irb(main):001:0> w = Wine.new(producer: 'Cayuse', vintage: 2009, designation: 'Flying Pig')
irb(main):002:0> w.varietals = [{name: 'Cabernet Franc', percentage: 60},{name:'Merlot', percentage: 40}]
irb(main):003:0> w.save

Since the JSON could be anything, Rails will default to treating it as a string, retuning the raw JSON. The scaffolded edit form looks like this.

edit inital

What I want is to create form fields for each of the JSON attributes on varietals, allow the user to add new varitals, or remove them. Pretty standard stuff for a normal has_many relationship with accepts_nested_attributes_for.

Making the form play well with JSON data

The solution I prefer is to create an inner class that is backed by the JSON stored attributes. Another valid option is to convert the JSON field to a struct in the view. I prefer adding a class for a few reasons:

  • It's more explicit when preseting the data, attribute methods can be used rather than accessing the data in a hash.
  • Data can be manipulated, cast, or ignored before it is stored.

If you want to allow any key-value pair to be stored in the JSON then the struct approach (linked to above) is the way to go.

Adding the Varietal class inside of Wine is straight forward. It's just a plain old Ruby object. The Wine#varietals method needs to be updated to convert the stored JSON to instances of Wine::Varietal.

# app/models/wine.rb
class Wine < ActiveRecord::Base

  def varietals
    read_attribute(:varietals).map {|v| Varietal.new(v) }
  end

  class Varietal
    attr_accessor :name, :percentage

    def initialize(hash)
      @name          = hash['name']
      @percentage    = hash['percentage'].to_i
    end
  end
end

The Wine show action can be updated to use the Wine::Varietal#name and #percentage accessors.

<--! /app/views/wines/show.html.erb -->
<p>
  <strong>Varietals:</strong>
  <% @wine.varietals.each do |varietal| %>
    <%= varietal.name %> (<%= varietal.percentage %>)<br />
  <% end %>
</p>

Now to the edit form. First, add a partial for editing the varietal fields:

<--! /app/views/wines/_varietal_fields.html.erb -->
<fieldset>
  <%= f.label :name %>
  <%= f.text_field :name %>
  <%= f.label :percentage %>
  <%= f.text_field :percentage %>
  <%= f.hidden_field :_destroy %>
  <%= link_to "remove", '#', class: "remove_fields" %>
</fieldset>

And loop over the varietals in the form partial:

<--! /app/views/wines/_form.html.erb -->
<div class="field">
  <%= f.fields_for :varietals, do |varietal_form| %>
    <%= render 'varietal_fields', f: varietal_form %>
  <% end %>
</div>

Viewing the form we get:

edit association

While the data for the varietals is actually there, we don’t see it in the form because ActionView still treats Wine#varietals as a single field. If we add the setter method varietals_attributes=(attributes) on the Wine model ActionView will treat varietals as a standard has_many relationship.

# /app/models/wine.rb
def varietals_attributes=(attributes)
  varietals = []
  attributes.each do |index, attrs|
    next if '1' == attrs.delete("_destroy")
    attrs[:percentage] = attrs[:percentage].try(:to_i)
    varietals << attrs
  end
  write_attribute(:varietals, varietals)
end

The form still has a problem though because ActionView will expect a few methods on Wine::Varietal. Adding the following to Wine::Varietal resolves the form errors by making Wine::Varietal act like an ActiveRecord object.

# /app/models/wine.rb
class Varietals
  ...
  def persisted?() false; end
  def new_record?() false; end
  def marked_for_destruction?() false; end
  def _destroy() false; end
end

Strong params also needs to be updated on the WineController:

def wine_params
  params.require(:wine).permit(:producer, :vintage, :designation,
                               varietals_attributes: [:name, :percentage, :_destroy])
end

This sets everything up for the edit form, but when we look at the new action on wine there are no varietal fields. We could add one in WineController#new action, or create an instance method on the Wine model. I’ll add the build method on the Wine model because it shows a gotcha to be aware of.

# app/models/wine.rb
def build_varietal
  v = self.varietals.dup
  v << Varietal.new({name: '', percentage: 0})
  self.varietals = v
end
# app/controllers/wines_controller.rb
def new
  @wine = Wine.new
  @wine.build_varietal
end

Wine#build_varietal calls self.variables.dup because while Wire#varietals returns an Array we can not add elements to it with the Ruby << method. We can only set the array in its entirety. The following console session shows the problem.

irb(main):015:0> w = Wine.new 
=> #<Wine id: nil, producer: nil, vintage: nil, varietals: [], designation: nil, created_at: nil, updated_at: nil>
irb(main):016:0> w.varietals << Wine::Varietal.new({}) 
=> [#<Wine::Varietal:0x007fc78b8a2258 @name=nil, @percentage=0>]
irb(main):018:0> w.varietals
=> []

Behind the scenes ActiveRecord is not persisting the JSON Array when using the << method so it effectively disappears. But we can still set the entire attribute using the equals setter.

irb(main):020:0> w.varietals = [Wine::Varietal.new({})] 
=> [#<Wine::Varietal:0x007fc78b8cb6d0 @name=nil, @percentage=0>]
irb(main):021:0> w.varietals 
=> [#<Wine::Varietal:0x007fc78b8d34c0 @name=nil, @percentage=0>]

Now we have a single model, Wine, that acts like it has_many varietals, but stores the varietal data in a JSON store.

Validation

Finally, we can add a validator to make sure no Wine can have a sum of Varietals over 100 percent:

# app/models/wine.rb
class Wine < ActiveRecord::Base
  validate :varietals_percentage

private
  def varietals_percentage
    if self.varietals.map(&:percentage).inject(:+) > 100
      self.errors.add(:varetals, "sum cannot be greater than 100")
    end
  end
end

The form partial we’ve created does not yet have an add button to add multiple Varietals to a Wine, and the remove buttons have not been hooked up. All that is required is some javascript to make it act like any other form used with accepts_nested_attributes_for. There is a Railscast that covers one way to handle the javascript, and a few gems that can be helpful.

I've posted all the code for this project on GitHub if you would like to see the entire app.

Summary: ActiveRecord PostgreSQL JSON storage can be used to replace has_many relationships and accepts_nested_attributes_for on Rails models. This can be helpful if the associated objects are always needed.