DEV Community

K Putra
K Putra

Posted on • Edited on

Rails + PostgreSQL JSONB (Part 1)

Rails + PostgreSQL JSONB Series
Part 1: Migration and CRUD
Part 2: store_accessor, Query, and Index
Part 3: Paginate JSONB Data

If you continue to read this article, I assume that you know Ruby, OOP in Ruby, RoR, and Active Record.

Not so many article cover this topic. Even the existing articles did not cover in details. I have to read many articles before I can perform well.

This is the first time I'll deep dive into a topic.

You need Rails >= 4.2, PostgreSQL >= 9.4

PostgreSQL has JSON and JSONB column type. They are look the same, but there are many differences, especially in functionality. I recommend to use JSONB instead of JSON, as JSONB column type is the upgraded version of JSON.

Why we should use JSONB column type?

The advantage of using jsonb is that you can easily integrate relational and non-relation data, with performance that can be better than most non-relational databases like MongoDB.

source: this article

Let's start our journey! (I use Rails API-only as example, but this article can be implemented in normal Rails as well)

Table of Contents:
1. Migration
2. Create
3. Show
4. Validation
5. Update
6. Final Word

1. Migration

It is as simple as any other column types.

# db/migrate/*_create_books.rb
class CreateBooks < ActiveRecord::Migration[5.2]
  def change
    create_table :books do |t|
      t.integer :user_id
      t.jsonb :payload, null: false, default: '{}'
    end
  add_index :books, :payload, using: :gin
  end
end
Enter fullscreen mode Exit fullscreen mode

Notice:

First, you don't have to use null: false, default: '{}', but I recommend to use it. Simply because you don't have to check if it is nil or not.

# if you allow nil OR dont't state default value to a hash
Book.create(user_id: 1)
Book.last.payload['title']
# => NoMethodError (undefined method `[]' for nil:NilClass)

# if you don't allow nil AND state default value to a hash
Book.create(user_id: 1)
Book.last.payload['title']
# => nil
Enter fullscreen mode Exit fullscreen mode

Second, we’re also defining a GIN index. I'll cover this in Part 2.

Third, I named the column name as payload, but you can named it as you want as long as ruby, rails, and postgresql allowed it.

2. Create

Create a record is very simple too:

book_payload = {
  title: 'Hacking Growth',
  publisher: 'Currency',
  published_date: '2017-04-07',
  authors: [
    {
      id: 1,
      name: 'Sean Ellis'
    },
    {
      id: 2,
      name: 'Morgan Brown'
    }
  ]
}

Book.create(user_id: 1, payload: book_payload)
Enter fullscreen mode Exit fullscreen mode

There you go!

3. Show

It is very simple to show the data. Note that any column defined as JSONB will be represented as a hash, with all keys are returned as string.

Book.last.user_id
# => 1

Book.last.payload['title']
# => "Hacking Growth"

Book.last.payload['authors'][0]['name']
# => "Sean Ellis"
Enter fullscreen mode Exit fullscreen mode

Now, if you want a custom serializer so you can access your JSON object using symbols as well, you can do like this:

# app/models/book.rb
class Book < ApplicationRecord
  belongs_to :user
  serialize :payload, JsonbSerializers
end

# app/serializers/jsonb_serializers.rb
class JsonbSerializers
  def self.dump(hash)
    hash.to_json
  end

  def self.load(hash)
    (hash || {}).with_indifferent_access
  end
end
Enter fullscreen mode Exit fullscreen mode
Book.last.payload[:title]
# => "Hacking Growth"
Enter fullscreen mode Exit fullscreen mode

4. Validation

The problems with JSOB column type is if you don't give validation then it will be schema free. You can input any schema to this column. Take a look at the code below:

book_payload = {
  title: 'Getting to Plan B',
  pubs: 'Harvard Business Review Press',
  authors: 'John W Mullins'
  rating: 3.5
}

Book.create(user_id: 1, payload: book_payload)
Enter fullscreen mode Exit fullscreen mode

The record above will be committed in DB. In most scenario, this is a big problem.

How to validate the params given by API consumer is right or wrong? We have to do two steps:

  1. Using JSON Schema
  2. Whitelist params

First step, we are using JSON Schema. I won't cover what JSON Schema is. The official page here is perfect enough to be understood.

How to use JSON Schema in Rails? We are using gems. There are few gems, but the only one I've ever use is activerecord_json_validator gems. (Take note that this gem only provide using draft-04 of JSON Schema)

Let's make our JSON Schema for our Book model. We can put the schemas wherever we want, but I like to put them in app/models/schemas.

To learn about JSON Schema, you can use this and this. Those two are enough.

// app/models/schemas/book_payload.json
{
  "$schema": "http://json-schema.org/draft-04/schema",
  "type": "object",
  "required": [ "title", "publisher", "published_date", "authors" ],
  "properties": {
    "title": { "type": "string" },
    "publisher": { "type": "string" },
    "published_date": { "type": "string" },
    "authors": {
      "type": "array",
      "items": {
        "$ref": "authors.json#/definitions/data"
      },
      "minItems": 1,
      "uniqueItems": true
    }
  }
}

// app/models/schemas/authors.json
{
  "definitions": {
    "data": {
      "type": "object",
      "required": [ "id", "name" ],
      "properties": {
        "id": { "type": "integer" },
        "name": { "type": "string" }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Then, we put to our Gemfile, and bundle install:

gem 'activerecord_json_validator'
Enter fullscreen mode Exit fullscreen mode

Then we update our model. (To learn about this gem, their github page is more than enough.)

# app/models/book.rb
class Book < ApplicationRecord
  ...

  PAYLOAD_SCHEMA = "#{Rails.root}/app/models/schemas/book_payload.json"
  validates :payload, presence: true, json: { message -> (err) { err }, schema: PAYLOAD_SCHEMA }
end
Enter fullscreen mode Exit fullscreen mode

First step is finish. Now, look at the code below:

book_payload = {
  title: 'Getting to Plan B',
  pubs: 'Harvard Business Review Press',
  authors: 'John W Mullins'
  rating: 3.5
}

Book.create!(user_id: 1, payload: book_payload)
Enter fullscreen mode Exit fullscreen mode

The code above will throw validation error, as the json is not valid. There are no publisher param, no publisher_date param, and authors param is not an array.

Then take a look at this code:

book_payload = {
  title: 'Getting to Plan B',
  publisher: 'Harvard Business Review Press',
  published_date: '2009-09-08',
  pubs: 'Harvard Business Review Press',
  authors: [
    {
      id: 3,
      name: 'John W Mullins',
      web: 'http://www.johnwmullins.com'
    }
  ],
  rating: 4.2
}

Book.create!(user_id: 1, payload: book_payload)
Enter fullscreen mode Exit fullscreen mode

The code above will NOT throw validation error! Because it is a valid json. And you can call pubs and rating too!

Book.last.payload['pubs']
# => "Harvard Business Review Press"

Book.last.payload['rating']
# => 4.2

Book.last.payload['authors'][0]['web']
# => "http://www.johnwmullins.com"
Enter fullscreen mode Exit fullscreen mode

Imagine if API Consumer give 100 parameters for every request, then your data storage will be used for garbages that you'll never need!

That's why we need the whitelisting params.

Second step, we are whitelisting params in controllers. This is necessary not only for JSONB column type, but for any other column types.

Let's update our controller:

# app/controllers/books_controller.rb
class BooksController < ApplicationController
  def create
    book = Book.create!(book_params)
    render json: { status: "OK", message: "Book created!", object: book }, status: 201
  end

  private

  def book_params
    params.permit(
      payload: [
        :title,
        :publisher,
        :published_date,
        authors: [
          :id,
          :name
        ]
      ]
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

You know how whitelisting params works. I think I don't have to explain the code above.

That's it. Now you have validation for your jsonb column.

5. Update

Note: All of the examples in this chapter are assuming you don't have validation for payload column in Book.

Updating JSONB column is a little tricky. Let say you only want to change the title of the last record of Book. If you do this:

Book.last.payload
# => {"title"=>"Hacking Growth", "publisher"=>"Currency", "published_date"=>"2017-04-07", "authors"=>[{"id"=>1, "name"=>"Sean Ellis"}, {"id"=>2, "name"=>"Morgan Brown"}]}

book_payload = {
  title: 'Blue Ocean'
}

Book.last.update(payload: book_payload)
Enter fullscreen mode Exit fullscreen mode

Now your payload only consist of title!

Book.last.payload
# => {"title"=>"Blue Ocean"}

Book.last.payload['publisher']
# => nil

Book.last.payload['authors']
# => nil
Enter fullscreen mode Exit fullscreen mode

This is the right way:

book = Book.last
book.payload['title'] = 'Blue Ocean'
book.save!

Book.last.payload
# => {"title"=>"Blue Ocean", "publisher"=>"Currency", "published_date"=>"2017-04-07", "authors"=>[{"id"=>1, "name"=>"Sean Ellis"}, {"id"=>2, "name"=>"Morgan Brown"}]}
Enter fullscreen mode Exit fullscreen mode

Tips for updating JSONB column:

# app/controllers/books_controller.rb
class BooksController < ApplicationController
  def update
    UpdateBookPayload.new(update_params).call
    render json: { status: "OK", message: "Book updated!" }, status: 200
  end

  private

  def update_params
    params.permit(
      :book_id,
      :title,
      :publisher,
      :published_date,
      authors: [
        :id,
        :name
      ]
    )
  end
end

# app/lib/update_book_payload.rb
class UpdateBookPayload
  def initialize(params)
    @params = params
    @book   = book
  end

  def call
    iterate_params
    @book.save!
  end

  private

  def book
    Book.find(@params[:book_id])
  end

  def iterate_params
    params = @params.delete('book_id')
    params.each do |key1, value1|
      if key1 == 'authors'
        iterate_authors(key1, value1)
      else
        @book.payload[key1] = (value1 || @book.payload[key1])
      end
    end
  end

  def iterate_authors(key1, value1)
    value1.each_with_index do |value2, key2|
      value2.each do |key3, value3|
        @book.payload[key1][key2][key3] = (value3 || @book.payload[key1][key2][key3])
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Therefor, the API Consumer can pass any json to BE. We can check the class in perform using rails console:

Book.last.id
# => 10

Book.last.payload
# => {"title"=>"Hacking Growth", "publisher"=>"Currency", "published_date"=>"2017-04-07", "authors"=>[{"id"=>1, "name"=>"Sean Ellis"}, {"id"=>2, "name"=>"Morgan Brown"}]}

params = {
  book_id: 10,
  title: 'Blue Ocean',
  authors: [
    {},
    { id: 5 }
  ]
}

UpdateBookPayload.new(params).call

Book.last.payload
# => {"title"=>"Blue Ocean", "publisher"=>"Currency", "published_date"=>"2017-04-07", "authors"=>[{"id"=>1, "name"=>"Sean Ellis"}, {"id"=>5, "name"=>"Morgan Brown"}]}
Enter fullscreen mode Exit fullscreen mode

6. Final Word

This is the end of Part 1. In Part 2, I'll cover about store_accessor, Query and Indexing.

source: myself and extract from many source, I don't save them, I just write what I remember

Top comments (5)

Collapse
 
pedromschmitt profile image
Pedro Schmitt

Thanks for this great article!

For Rails 6 I had this error:
undefined method 'with_indifferent_access' for "{}":String'

So I needed to change the serializer:

From:

# app/serializers/jsonb_serializers.rb
class JsonbSerializers
  def self.dump(hash)
    hash.to_json
  end

  def self.load(hash)
    (hash || {}).with_indifferent_access
  end
end
Enter fullscreen mode Exit fullscreen mode

To:

# app/serializers/jsonb_serializers.rb

class JsonbSerializers
  def self.dump(hash)
    hash.to_json
  end

  def self.load(hash)
    if hash.nil?
      {}
    elsif hash.is_a?(Hash) && hash.empty?
      hash
    else
      JSON.parse(hash)
    end.with_indifferent_access
  end
end

Enter fullscreen mode Exit fullscreen mode
Collapse
 
bcotteret profile image
Bruno Cotteret

Nice article !
Just to mention, the schema requires a PathName not a string as per this isssue:
github.com/mirego/activerecord_jso...
So

PAYLOAD_SCHEMA = "#{Rails.root}/app/models/schemas/book_payload.json" 
Enter fullscreen mode Exit fullscreen mode

should be:

PAYLOAD_SCHEMA = Rails.root.join("app","models","schemas", "book_payload.json")
Enter fullscreen mode Exit fullscreen mode

Finally there is a missing '#' for the schema path at the end:

// app/models/schemas/book_payload.json
{
  "$schema": "http://json-schema.org/draft-04/schema#",
Enter fullscreen mode Exit fullscreen mode
Collapse
 
kamalpanhwar profile image
Kamaluddin Panhwar • Edited

Very nice and detailed, I have seen following line giving error on rails 6

validates :payload, presence: true, json: { message -> (err) { err }, schema: PAYLOAD_SCHEMA }
Enter fullscreen mode Exit fullscreen mode

So change it to

validates :payload, presence: true, json: { message: -> (err) { err }, schema: PAYLOAD_SCHEMA }
Enter fullscreen mode Exit fullscreen mode

Also in class I am getting error so may be we need to remove to_json part of serializer. not sure but I think that would be solution. so I removed it from class

class JsonbSerializers
  require 'active_support/core_ext/hash/indifferent_access'
  def self.dump(hash)
    hash
  end
Enter fullscreen mode Exit fullscreen mode
Collapse
 
hammady profile image
Hossam Hammady

Great article. A small correction in the migration:

t.jsonb :payload, null: false, default: {}
Enter fullscreen mode Exit fullscreen mode

Note the unquoting of the default value. Tested on Rails 5.

Collapse
 
superails profile image
Yaroslav Shmarov

Very good knowledge. Thank you