Why use JSON in a Rails app?
JSON is a robust and well-supported data storage and transfer format. It's very useful when transferring data between frontend and backend, or between services.
It's also useful for storing data in the database when you don't know the structure of the data in advance, or you want to change the structure frequently and don't want to have to migrate the database every time.
Used correctly it can be helpful in creating isomorphism between the frontend and backend, and in creating a more flexible and maintainable app.
What you'll build
In this pattern we'll be learning the ins and outs of using JSON in a Rails app.
We start with learning about the different ways to store JSON in the database and how to query it.
Then we'll look at how to validate JSON data and how to serialize and deserialize it.
Finally we'll look at how to transfer JSON data to the view.
App setup
We start with the pattern_base1 base app which can also be created with the following commands:
APP_NAME=json-masterclass
rails new $APP_NAME -d postgresql -c tailwind --skip-jbuilder --skip-test
cd $APP_NAME
git add -A && git commit -m "rails new $APP_NAME -d postgresql --skip-jbuilder --skip-test"
We use the default test setup
rails db:create
rails g scaffold app data:jsonb --no-helper --no-test-framework
rails db:migrate
Data layer
Database choices
First off if you want to store JSON data in your Rails app you'll really want to be using PostgreSQL as your database. It has the best and most established JSON support of all the major databases. See the Postgres JSON type reference, the Postgres JSON Functions and Operators reference and the Rails PostgreSQL Guide for more information on Rails support.
MySQL JSON is supported in Rails despite not being well documented, see the MySQL JSON Data Type reference, and the Mysql JSON Functions reference.
SQLite also has JSON support, see SQLite JSON1 reference. The plugin is compiled into the database by default and the functions and operators are designed to be compatible with PostgreSQL and MySQL.
Creating columns
To create a column on an existing table:
rails g migration AddDataToApps data:jsonb
Like most data types the default will be nil
so add a default to the column. This can be anything that serializes to JSON. Arrays, hashes, or anything that responds to to_json
:
class AddDataToApps < ActiveRecord::Migration[7.1]
def change
add_column :apps, :data, :jsonb, default: {}
end
end
JSON vs JSONB
The json
type stores the json as text in the database. It will be serialized and deserialized, but cannot be queried or indexed. By contrast the jsonb
type is stored as a binary format which means it can be queried and indexed by internal properties of the JSON object. Point updates can be made and in general updates are faster. jsonb
is the modern recommended choice, you would only choose the older json type for legacy reasons, or for databases which don't support jsonb
.
Querying JSON
JSON in Postgres supports a number of functions and operators. The operators are generally most useful, they all extract data from the JSON object and return it to the query. Here are some examples:
->
Get JSON object field as JSON
Here we're comparing the JSON object in the database to a JSON object in the query.
App.where(%[data->'profile' = ?], {"name":"Jon"}.to_json)
Here we're using virtual attributes with SELECT
to extract the JSON object from the database and return it as a Ruby object.
class App < ActiveRecord::Base
attribute :profile, :jsonb, default: {}
end
App.select("data->'profile' AS profile").first
# #<App:0x000000011d177be0 id: nil, profile: {"name"=>"Jon"}>
->>
Get JSON object field as text
Here' we're extracting the text value of a JSON object field and comparing it to a string in the query. You can see we use the ->
operator to get to the node and then the ->>
operator to extract the text value.
App.where(%[data->'profile'->>'name' = ?], "Jon")
#>
Get JSON object at path as object
The #>
operator is is another way of getting at JSON object fields by using a path. Here we're getting the profile
object from the JSON object and then getting the name
field from that object.
Note we have to convert the string to JSON in order to compare it to the JSON object that #>
returns.
Note also that '{profile,name}'
is in fact an array in SQL.
App.where(%[data#>'{profile,name}' = ?], "Jon".to_json)
Here's another way to extract the profile object
App.select("data#>'{profile}' AS profile").first
#>>
Get JSON object at path as text
This is the same as #>
but returns the text value of the node.
App.where(%[data#>>'{profile,name}' = ?], "Jon")
-
Delete key/value pair or string element from JSON object
Here we delete the whole profile attribute from the JSON object.
App.where(%[data#>>'{profile,name}' = ?], "Jon").update_all("data = data - 'profile'")
#-
Delete key/value pairs at path from JSON object
Here we just delete the name attribute from the profile object.
App.where(%[data#>>'{profile,name}' = ?], "Jon").update_all("data = data #- '{profile,name}'")
Scopes
You can use the above operators to create scopes on your model. For example:
scope :with_country, ->(country) { where("data->>'country' = ?", country) }
There are many more functions for specific use cases but these will get you far.
Point updates
Updating a specific deeply nested node in your JSON object can be useful, but isn't supported natively. Postgres does include a json_set
function which includes a create_if_missing
option, but it only works if all the previous nodes in the path exist.
To create a deep node with all the intermediate nodes we're going to need to add a custom SQL function to our app.
First we're going to add the fx gem which allows us to migrate SQL functions in the same way that we migrate data in our app. Then we'll generate the function migration like so:
rails generate fx:function jsonb_set_deep
This will generate a migration file and a SQL function file. Edit it as follows
-- db/functions/jsonb_set_deep_v01.sql
CREATE OR REPLACE FUNCTION jsonb_set_deep(target jsonb, path text[], val jsonb)
RETURNS jsonb AS $$
DECLARE
k text;
p text[];
BEGIN
IF (path = '{}') THEN
RETURN val;
ELSE
IF (target IS NULL) THEN
target = '{}'::jsonb;
END IF;
FOREACH k IN ARRAY path LOOP
p := p || k;
IF (target #> p IS NULL) THEN
target := jsonb_set(target, p, '{}'::jsonb);
ELSE
target := jsonb_set(target, p, target #> p);
END IF;
END LOOP;
-- Set the value like normal.
RETURN jsonb_set(target, path, val);
END IF;
END;
$$ LANGUAGE plpgsql;
Then we add a helper method in the model:
# app/models/app.rb
class App < ActiveRecord::Base
def set_json(attr, path, value)
self.class.where(id: id).update_all(["#{attr} = jsonb_set_deep(#{attr}, '{#{path.join(', ')}}', ?)", value.to_json])
end
end
And use it like so:
a.set_json(:data, [:level1, :level2, :level3], "Value")
Deleting and renaming
# app/models/app.rb
def delete_json_attr(path)
self.class.where(id: id).update_all(["data = data #- '{#{path.join(', ')}}'"])
end
def rename_json_key(path, new_key)
new_path = path.dup.tap { |p| p.pop; p.push(new_key) }
where_clause = "data #> '{#{path[0..-2].join(', ')}}' ? '#{path.last}'"
update_clause = "data = jsonb_set(data #- '{#{path.join(', ')}}', '{#{new_path.join(', ')}}', data #> '{#{path.join(', ')}}')"
self.class.where(id: id).where(where_clause).update_all(update_clause)
end
Paths
A path to any point in the JSON is an array of strings, numbers or any other value that can be used as a key in JSON.
Model layer
JSON Schema
An easy way to validate your JSON data is to use the json-schema gem which uses JSON Schemas to validate JSON.
bundle add activerecord_json_validator
# app/models/app.rb
class App < ActiveRecord::Base
APP_DATA_JSON_SCHEMA = Rails.root.join('config/schemas/app_data.json_schema')
validates :data, presence: true, json: { schema: APP_DATA_JSON_SCHEMA }
end
// config/schemas/app_data.json
{
"type": "object",
"$schema": "http://json-schema.org/draft-04/schema#",
"properties": {
"city": { "type": "string" },
"country": { "type": "string" },
"profile": {
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" }
},
"required": ["name"]
}
},
"required": ["country"]
}
Serialization and deserialization
By default Rails will deserialize the binary JSON blob or string stored in the database into a Ruby object which can be used immediately. Likewise changes made to that Ruby object will be serialized and saved to the database along with any other changes made to the model.
In many cases that Hash or Array is sufficient, but as the complexity of the app and its dependency on the JSON data grows you may want to start adding functions that operate on the data. At this point you probably want to replace the default deserialization and deserialize to an object.
This can be done in vanilla Rails like so:
# app/models/app.rb
serialize :data, coder: Datum
delegate :user, :table, :user_table, to: :data
Now let's define our serialization target. This is a plain old Ruby object that defines the dump
and load
methods. Here we also include some ActiveModel modules to make the definition a little cleaner.
# app/models/datum.rb
class Datum
include ActiveModel::Attributes
include ActiveModel::AttributeAssignment
include ActiveModel::Serializers::JSON
attribute :user, :string, default: ''
attribute :table, :string, default: ''
def user_table
"#{user}_#{table}"
end
def self.dump(value)
value
end
def self.load(value)
return new if value.blank?
new.tap do |datum|
datum.assign_attributes value
end
end
end
Other serialization options
If you have a lot of attributes or you need array types the above can get a little verbose. In that case you can use the attr_json gem or the json_attribute gem. Both of these gems give more control over the serialization and deserialization process. I've personally used attr_json
and it's worked well for me.
to_json
vs as_json
All objects in Rails have both of these functions. While to_json
converts an object into a JSON string. as_json
converts an object into the ruby version of the JSON object. Generally to_json
should be left alone but you can override the default as_json
on a Rails object like so:
# app/models/app.rb
def as_json(options = {})
super({
include: %i[associations],
methods: %i[users]
}.reverse_merge(options))
end
This shows you how to add associations and other methods to the returned hash, and allow further options (:only
and :except
) to be passed through
Note these are associations and methods on the ActiveRecord object. If you have a JSON data column and you want a nested JSON column in the output you want to create a function for it on the serialization target.
Controller and view layers
Once your JSON data is in the database and you've deserialized it into an object you'll probably want to make it available to the view. There are a number of ways to do this:
API data transfer
The easiest way to transfer JSON to the view is to respond to the JSON format in your controller:
# app/controllers/apps_controller.rb
class AppsController < ApplicationController
def show
respond_to do |format|
format.html
format.json { render json: @app }
end
end
end
Then you can fetch the JSON data from the server using JavaScript and update the view. This is a common pattern in React and other JavaScript frameworks:
// app/javascript/controllers/application.js
document.addEventListener("turbo:load", e => {
fetch('/apps/4.json')
.then(response => response.json())
.then(data => {
console.log(data)
})
})
Jbuilder
If your json needs are more complex you can use the Jbuilder gem to create a view for your JSON data. Jbuilder is included by default in most Rails apps but we created this one without it, so let's add it to the gemfile first:
bundle add jbuilder
Then add a view for your JSON data:
# app/views/apps/show.json.jbuilder
json.array! @apps do |app|
json.id app.id
json.data app.data
end
And fetch it on the frontend:
// app/javascript/controllers/application.js
document.addEventListener("turbo:load", e => {
fetch('/apps.json')
.then(response => response.json())
.then(data => {
console.log(data)
})
})
No need to change the controller as Jbuilder will automatically render the view if it exists and the URL ends in .json
.
The great thing about JBuilder is that it allows you to assemble a more complex JSON object from multiple view partials.
Gon
If you wan't your object globally available you can use the gon gem. This gem allows you to set a global variable in your view which can be accessed in your JavaScript.
bundle add gon
<% # app/views/layouts/application.html.erb %>
<head>
<title>some title</title>
<%= Gon::Base.render_data %>
...
</head>
class AppsController < ApplicationController
before_action :set_gon, only: %i[ show edit ]
private
def set_gon
gon.push({ app: @app.as_json })
end
end
Then anywhere in your JavaScript you can access the gon
variable:
console.log(gon.app)
Stimulus controllers
If you're using Stimulus you can define a value of type Object
on the controller and make it available in the controller like so:
<% # app/views/apps/show.html.erb %>
<div class="mx-auto md:w-2/3 w-full flex" data-controller="app" data-app-data-value="<%= @app.data.to_json %>">
</div>
// app/javascript/controllers/app_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { data: Object }
connect() {
console.log(this.dataValue)
}
}
Stimulus automatically deserializes the JSON string into a JavaScript object.
JSON in React
If you follow our React on Rails guide you'll be able to drop your JSON as props into your React components like so:
<%= react_component('Post', props.as_json, camelize_props: true) %>
// app/javascript/components/Post.js
import React from 'react'
export default function Post({ title, published, publishedAt }) {
return (
<div>
<h1>{title}</h1>
<p>{published ? 'Published' : 'Draft'}</p>
<p>{publishedAt}</p>
</div>
)
}
As you can see we've used the camelize_props
option to convert the snake_case keys to camelCase. If you want to do this globally
# config/initializers/react_on_rails.rb
Rails.application.config.react.camelize_props = true
Key transformation
In Ruby the convention is to use snake_case for variable names and in JSON the convention is to use camelCase. Most linters will complain if you use the wrong case and it will make bugs more likely. So you'll probably want to transform your JSON keys to camelCase on the way out of the server. and then back to snake_case on the way in.
Let's start by adding a camelized_json
function to our ApplicationRecord
class. This is the class that all our models inherit from so the function will be available to all of them. You could of course add it only to the models that need it.
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
KEYS_FOR_VALUE_INFLECTION = %w[type].freeze
def as_camelized_json(options = {})
as_json(options)
.deep_dup
.deep_transform_values_with_key! { |k, v| KEYS_FOR_VALUE_INFLECTION.include?(k) ? v.camelize(:lower) : v }
.deep_transform_keys { |k| k.to_s.camelize(:lower) }
end
end
Note we also allow for a list of values that can be inflected as well. This is useful for string constants.
We can then use it in our controller like so:
class AppsController < ApplicationController
def show
respond_to do |format|
format.html
format.json { render json: @app.as_camelized_json }
end
end
end
To do it on the way back:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :underscore_params
private
def underscore_params
params.deep_transform_keys! { |k| k.is_a?(String) ? k.underscore : k }
params.deep_transform_values_with_key! { |k, v| ApplicationRecord::KEYS_FOR_VALUE_INFLECTION.include?(k) ? v.underscore : v }
end
end
Depending on your needs you can do this globally as above or just on specific controllers.
In JBuilder you can use the key_format!
and deep_format_keys
function to do the same thing:
json.key_format! camelize: :lower
json.deep_format_keys true
Or to apply it across the entire app:
# config/initializers/jbuilder.rb
Jbuilder.key_format camelize: :lower
Jbuilder.deep_format_keys true
Another option to solve this problem is the olive_branch gem.
JSON Editor
If you want the ability to edit your JSON in a form you can use the vanilla-jsoneditor package. It offers formatting, validation, syntax highlighting and other features. It's great for admin areas.
Add the package to your app:
yarn add vanilla-jsoneditor
# or
bin/importmap pin vanilla-jsoneditor
Add a Stimulus controller wrapper:
// app/javascript/admin/json_editor_controller.js
import { Controller } from '@hotwired/stimulus'
import { JSONEditor } from 'vanilla-jsoneditor'
export default class extends Controller {
static targets = ["textarea", "editor"]
connect() {
let content = { text: this.textareaTarget.value }
const target = this.editorTarget
const onChange = (updatedContent, previousContent, { contentErrors, patchResult }) => {
this.textareaTarget.value = updatedContent.text
content = updatedContent
}
const editor = new JSONEditor({ target, props: { content, mode: 'text', onChange }})
}
}
Then in your views
<% # app/views/apps/_form.html.erb %>
<div data-controller="json-editor">
<%= form.label :settings %>
<%= form.text_area :settings, value: form.object.settings.to_json, data: { 'json-editor-target': 'textarea' }, class: "hidden" %>
<div data-json-editor-target="editor" style="width: 400px; height: 400px;"></div>
</div>
Testing
Fixtures
If your testing your JSON app there's a good chance you'll want to load fixtures into your suite. You can do this by adding a test helper:
# spec/support/helpers.rb
module Helpers
def json_data(filename)
file_content = file_fixture("json_data/#{filename}.json").read
JSON.parse(file_content, symbolize_names: true)
end
end
Then create your fixture:
// spec/fixtures/files/json_data/app_data.json
{
"name": "Jon",
"age": 40
}
Load it in a factory:
# spec/factories/apps.rb
FactoryBot.define do
factory :app do
data { json_data :app_data }
end
end
And use it in your tests:
RSpec.describe App, type: :model do
let(:app) { create :app }
it 'has data' do
expect(app.data).to eq({ name: 'Jon', age: 40 })
end
end
TODO
- attr_json
- Param wrapping for API
- A JSON DSL concern with the following features:
- Data layer
- Model layer
- Transfer layer
- Frontend layer
- Testing
- Allowing JSON through params
- Separate controller for various JSON actions