DEV Community

loading...

Rails + PostgreSQL JSONB (Part 2)

kputra profile image K Putra Updated on ・5 min read
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, Active Record, and PostgreSQL Query Language.

Before we start, if you haven't read Part 1, here is our schema:

# 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
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

Let's start our journey!

Table of Contents:
1. store_accessor
2. Query
3. Index
4. Final Word

1. store_accessor

If you frequently access some attributes, you can use store_accessor. When you use store_accessor, you can call the column like normal column type.

# app/models/book.rb
class Book < ApplicationRecord
  belongs_to :user
  serialize :payload, JsonbSerializers
  store_accessor :payload, :title, :publisher, :authors
end
Enter fullscreen mode Exit fullscreen mode
Book.last.payload['title']
# => "Hacking Growth"

Book.last.title
# => "Hacking Growth"

Book.last.publisher
# => "Currency"

Book.last.authors
# => [{"id"=> 1, "name"=>"Sean Ellis"}, {"id"=>2, "name"=>"Morgan Brown"}]
Enter fullscreen mode Exit fullscreen mode

This is not the only usability of store_accessor. More about it:

# Create
Book.create(user_id: 1, title: 'The godvader', publisher: 'Mario', published_date: '2002-03-01', authors: [{ id: 19, name: 'Mario Puzo' }])

# Update
book = Book.last
book.title = 'The Godfather'
book.publisher = 'NAL'
book.save
Enter fullscreen mode Exit fullscreen mode

You can add validation too if you want! By this, you can combine JSON Schema and Active Record Validations for more powerful validation.

# app/models/book.rb
class Book < ApplicationRecord
  belongs_to :user
  serialize :payload, JsonbSerializers
  store_accessor :payload, :title, :publisher, :authors

  validates :title, length: { in: 3..50 }
end
Enter fullscreen mode Exit fullscreen mode

In short, store_accessor is just a shortcut which defines getter and setter methods.

BUT, store_accessor does not allow you to access nested keys. You can only access the first layer. Say, you have this schema inside column payload in a model named City:

{
  geolocation: {
    latitude: 48.856613,
    longitude: 2.352222
  },
  detail: {
    name: "Paris",
    url: "https://www.latlong.net/c/?lat=48.856613&long=2.352222"
  }
}
Enter fullscreen mode Exit fullscreen mode

You can only do this:

# app/models/city.rb
class City < ApplicationRecord
  store_accessor :payload, :geolocation, :detail
end
Enter fullscreen mode Exit fullscreen mode

You can't access latitude, longitude, name, and url using store_accessor.

If you still want to do something like City.last.geolocation_latitude, then we can set our getter and setter methods manually:

# app/models/city.rb
class City < ApplicationRecord
  store_accessor :payload, :geolocation, :detail

  def geolocation_latitude
    self.geolocation['latitude']
  end

  def geolocation_latitude=(value)
    self.geolocation['latitude'] = value
  end

  # add these getter and setter each for 3 more times:
  # geolocation_longitude, detail_name, detail_url
end
Enter fullscreen mode Exit fullscreen mode

This way, you can access them as it is using store_accessor.

city = City.last
city.geolocation_latitude
# => 48.856613

city.detail_name
# => "Paris"

city.detail_name = 'Rome'
city.save

city.detail
# => {"name"=>"Rome", "url"=>"https://www.latlong.net/c/?lat=48.856613&long=2.352222"}
Enter fullscreen mode Exit fullscreen mode

Tips: You can implement Ruby Metaprogramming, so you don't have to create getter and setter methods one by one:

# app/models/city.rb
class City < ApplicationRecord
  store_accessor :payload, :geolocation, :detail
  SCHEMA = {
    'geolocation' => ['latitude', 'longitude'],
    'detail' => ['name', 'url']
  }

  SCHEMA.each do |key, val|
    val.each do |method|
      define_method "#{key}_#{method}" do
        self.send("#{key}")[method]
      end

      define_method "#{key}_#{method}=" do |arg|
        self.send("#{key}")[method] = arg
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

I've never done this before, because, as I've mentioned, we use store_accessor for some attributes that we use frequently.

2. Query

I'll just explain queries that I frequently used. Please refer to official documentation of PostgreSQL for full information about query.

Using JSONB column type, we can't rely 100% using ORM. We have to understand basic query, especially JSONB type query.

# Matches where 'Book' contains 'title': 'Hacking Growth'
Book.where("payload ->> 'title' = :title", title: 'Hacking Growth')

# This is just the same as above, but not save from SQL injection
Book.where("payload ->> 'title' = ?", "Hacking Growth")

# Same as the first example
Book.where("payload @> ?", { title: 'Hacking Growth' }.to_json)
Enter fullscreen mode Exit fullscreen mode
# Matches where 'Book' first author's name is Sean Ellis
Book.where("payload -> 'authors' -> '0' ->> 'name' = :name", name: 'Sean Ellis')

# Same as above
Book.where("payload #>> '{authors,0,name}' = :name", name: 'Sean Ellis'

# Same as above
Book.where("payload -> 'authors' -> '0' @> :val", val: { name: 'Sean Ellis' }.to_json)
Enter fullscreen mode Exit fullscreen mode

Now, go back to our City model from #1.

# Matches where 'City' contains 'detail':{'name': 'Paris'}
City.where("payload -> 'detail' ->> 'name' = :name", name: 'Paris')

# Same as above
City.where("payload #>> '{detail,name}' = :name", name: 'Paris')

# Same as above
City.where("payload @> :val", val: { detail: { name: 'Paris'}}.to_json)

# Same as above
City.where("payload -> 'detail' @> :val", val: { name: 'Paris'}.to_json)
Enter fullscreen mode Exit fullscreen mode
# Search City that has name AND url in payload['detail']
City.where("payload ->> 'detail' ?& array[:keys]", keys: ['name', 'url'])

# Search City that has name OR url in payload['detail']
City.where("payload ->> 'detail' ?& array[:keys]", keys: ['name', 'url'])
Enter fullscreen mode Exit fullscreen mode

Notice:

payload do not use quotation mark ('') because it is the name of the column. Whereas the attributes of the json always use quotation mark (''). Remember, you can name your column as you want, as long as ruby, rails, and postgresql allowed it.

When to use -> and ->> ? We use the -> operator to keep returning objects until we reach the final attribute where it’s ok to use ->> to return as text.

So if you have:

{
  this: {
    very: {
      deep: {
        nested: 'yeah'
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

You do:

Model.where("payload -> 'this' -> 'very' -> 'deep' ->> 'nested' = :val", val: "yeah")

# Btw, this is shorter:
Model.where("payload #>> '{this,very,deep,nested}' = :val", val: "yeah")
Enter fullscreen mode Exit fullscreen mode

If you want to search for integer/float, you need to add explicit type casts:

Book.where("(payload #>> '{authors,0,id}')::int = :val", val: 1)

City.where("(payload -> 'geolocation' ->> 'latitude')::float = :val", val: 48.856613)
Enter fullscreen mode Exit fullscreen mode

3. Index

Add index for JSONB column types is supported for rails >=5.0.0. I only cover for rails >=5.0.0.

Mostly, we are using GIN index. (Read this official documentation about GIN/GiST index types for more information.)

Let say we want to add partial index for title in our Book model, and we want to add partial index for name of the first author. Create it is as simple as this:

# 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, "payload ->> 'title'", using: :gin, name: "index_pictures_on_title"

  add_index :books, :payload, "payload #>> '{authors,0,name}'", using: :gin, name: "index_pictures_on_first_author_name"
  end
end
Enter fullscreen mode Exit fullscreen mode

4. Final Word

This is the end of Part 2, and also the end of Series Rails + PostgresQL JSONB. I'll update this Series if I found something useful.

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

Discussion (0)

pic
Editor guide