Skip to content

Nested inputs for key value hash attributes

Yosef Benny Widyokarsono edited this page May 29, 2022 · 8 revisions

This details how to create for a inputs for a Postgres JSON or JSONB column. JSON and JSONB support was added in Rails 4.2+.

Lets say you have a Post model with a JSON or JSONB column custom_fields. Rails accepts attributes for JSON columns as a hash:

{
  post: {
    title: "Nested inputs for key/value hash attributes."
    custom_fields: {
      hash_tags: "#rails,#simple_form,#postgres"
    }
  }
}

If this was a nested model we could have added out inputs by:

<%= simple_form_for(@post) do |f|
  f.input :title
  f.simple_fields_for(@post.custom_fields) do |cf|
    cf :hash_tags
  end
end %>

But this fails since our lowly hash does not respond to model_name which is part of the ActiveModel api. cf :hash_tags will also not work since our hash does not respond to hash_tags.

The solution is to dig out the old proxy or delegator pattern in the form of a decorator:

class CustomFieldsDecorator
  # @see http://api.rubyonrails.org/classes/ActiveModel/Naming.html
  MODEL_NAME = ActiveModel::Name.new(self.class, nil, 'custom_fields')

  def model_name
    MODEL_NAME
  end

  def initialize(hash)
    @object = hash.symbolize_keys
  end

  # Delegates to the wrapped object
  def method_missing(method, *args, &block)
    if @object.key? method
       @object[method]
    elsif @object.respond_to? method
       @object.send(method, *args, &block)
    end
  end

  def has_attribute? attr
    @object.key? attr
  end
end

Since our Decorator delegates to the hash we can call enumerable methods.

<% custom_fields = CustomFieldsDecorator.new(@post.custom_fields) %>
<%= simple_form_for(@post) do |f|
  f.input :title
  f.simple_fields_for(custom_fields) do |field|
    custom_fields.each do |key, value|
      field.input key
    end
  end
end %>

Solution 2: This solution, as proposed, works when you have a limited and known JSON objects. Create a Serializer like this:

# app/serializers/hash_serializer.rb
class HashSerializer
  def self.dump(hash)
    hash.to_json
  end

  def self.load(hash)
    (hash || {}).with_indifferent_access
  end
end

In order to access JSON object as symbols and add the following to the model:

class Post < ActiveRecord::Base
  serialize :preferences, HashSerializer
  store_accessor :preferences, :blog, :github, :twitter
end

It is now possible to directly access preferences objects like blog or twitter: post.blog, post.twitter, etc.

PS: make sure you have active_model_serializers gem

Solution 3:

It is something that sits between #1 and #2. Basically, it uses OpenStruct in the view and works well with known keys.

<%= simple_form_for(@post) do |f|
  f.input :title
  f.simple_fields_for :custom_fields, OpenStruct.new(@post.custom_fields) do |field|
    field.input :twitter
    field.input :github
  end
end %>