Rails Forms, JSON Columns, and Nested Parameters
Looks like you’ve decided to use a JSON column for your PostgreSQL (or in MySQL 5.7.8!) database. There are lots of reasons why developers end up using JSON columns — whether it’s just to store a blob of data, or you’re prototyping something and want to move quickly, JSON columns allow for a lot of flexibility when developing applications.
One of the nicer conveniences of JSON data is the ability to have nested data. In the past, if you wanted nested data in relational databases, you had to do INNER JOINs across a join table. This became an even bigger headache if your data is nested multiple levels. But with JSON columns, that complexity is washed away. You might be using them for that reason, and using Rails forms to update them in a sane way.
Here I’ll go into building Rails forms that update JSON columns with nested data.
Updating with Nested Parameters
Let’s say you have the model Article
and you have to update the JSON content
. Your form probably looks like this:
<%= form_with(model: article, local: true) do |form| %>
<%= form.label :content %>
<%= form.text_area :content %>
<% end %>
Perhaps, for certain articles, you want to render some special text if it’s in the content, so you want to split out the body text separately. And maybe, there’s a list of items you optionally want to show. For those cases, you can build your form like so:
<%= form_with(model: article, local: true) do |form| %>
<%= label_tag 'article[content][body]', 'Body' %>
<%= text_area_tag 'article[content][body]', article.content['body'] %>
<%= label_tag 'article[content][text]', 'Text' %>
<%= text_field_tag 'article[content][text]', article.content['text'] %>
<% article.content['list_items'].each do |item| %>
<%= label_tag 'article[content][][list_item][text]', 'Item' %>
<%= text_field_tag 'article[content][][list_item][text]', item['text'] %>
<% end %>
<% end %># And this is how it'll look in your controller:{
"article": {
"content": {
"body": "Body text here",
"text": "Very special text",
"list_items": [
{"text": "Item 1 text"},
{"text": "Item 2 text"}
]
}
}
}
There’s a few things to point out:
- We’re not using the Rails
fields_for
helper. Unfortunately, thefields_for
helper assumes that an attribute can be called as a method on your object, as inarticle.content.text
. This can be solved by representing yourcontent
as a Ruby object or aHashie::Mash
, for example - As a result, we’re using the lower-level
label_tag
andtext_field_tag
instead ofform.label
so we can represent the correct data and ids
Permitted Parameters
Remember that you have to whitelist parameters in Rails. That includes nested parameters! Here’s what it should look like:
def post_params
params.require(:article)
.permit(content: [
:body,
:text,
list_items: [:text]])
end
Checkboxes
Adding checkboxes to a form is a good way to represent boolean flags that you want to update. Normally, the Rails check_box
helper will add a hidden input tag before your checkbox, like so:
<input name="article[flag]" type="hidden" value="0">
<input type="checkbox" value="1" name="article[flag]">
However, this gets a little tricky with nested arrays. As noted in the Form Helper docs, Rails depends on duplicate parameters in order to give the checkbox precedence over the hidden tag, if it’s checked. This logic is actually found in Rack when it parses form data for a Rack::Request
that Rails and ActionDispatch eventually pass through to your controller. It basically looks like this:
article[flag]=0 # this is data from the hidden input tag
article[flag]=1 # this is from the actual checkbox, always _after_ the hidden tagparams = {} # for simplicity, let's assume params is a Hashparams[article][flag] = 0
params[article][flag] = 1 # the checkbox "overwrites" the data from the hidden tag
This only works if your checkbox is in a nested Hash — but if it’s an Array, Rack doesn’t have a way of figuring out which Array element has to be overwritten with subsequent checkbox data.
The good news is that you may not need a hidden tag to begin with. If you’re updating a JSON column, the absence of a value is sufficient to determine a “false” state. Thus, you can use a check_box_tag
. Let's add it to our example before:
<% article.content['list_items'].each do |item| %>
<%= label_tag 'article[content][][list_item][text]', 'Item' %>
<%= check_box_tag 'article[content][][list_item][flag]', true, ActiveRecord::Type::Boolean.new.cast(item['flag']) %>
<%= text_field_tag 'article[content][][list_item][text]', item['text'] %>
<% end %>
Remember that the form is going to send your data as a String, and if you have a nested boolean in your JSON, that’ll be persisted as a String since Rails won’t iterate through the JSON to convert it. The method ActiveRecord::Type::Boolean.new.cast
allows you to cast that String to and from a boolean as you need.
Summary
We looked at how to update a JSON column through Rails forms, particularly nested data, and covered a few gotchas with checkboxes. This should get you started with building out your forms. I also highly recommend reading through the Form Helper docs to familiarize yourself with what’s possible.
If you have any questions or have a better way, my handle is @shay_ker on Twitter. And if you found this helpful,