Custom queries on JSONB column


Few articles ago we created a Customer model with custom fields, based on JSONB column in PostgreSQL.

Today, let’s build a field searcher, so you can search on custom fields by case independent value with %LIKE% syntax.

Filterable module

This module should be included into your controller like usual include Filterable.

First of all, we should initialize it with params hash with fields and filter fields.

Let’s say like this: order=asc&page=1&filter=KZN&fields=city.

Example of Filtration class usage in controller:

searcher = Filterable::Filtration.new(params)
default_search = searcher.search_params

Let’s check some source code for Filtration class:

module Filterable
  class Filtration
    
    attr_accessor :search_strings, :fields, :query, :fields_array, :query_array

    def initialize(params)
      @fields = params[:fields]
      @query = params[:filter]
      @fields_array = []
      @query_array = []
      @search_strings = []
    end

    ...

    # FILTER DEFAULT PARAMS AND SEARCHABLE, AND BUILD CORRECT HASH
    def build_hash(fields, filters)
      data = []
      default = Customer::COLUMNS
      h = {}
      fields.zip(filters) { |a,b| h[a.to_sym] = b } 
      h.each do |k, v|
        if (v && v != '') && !default.include?(k.to_s)
          data << { name: k, value: v&.downcase }
        end
      end
      data
    end

    ...

    # CREATE SEARCH SQL STRINGS FOR SEARCHER
    def search_params
      result = ''
      to_delete = []
      if @fields_array && @fields_array.any?
        data = []
        @search_strings.each_with_index do |header, index|
          data << "u.#{header} ilike '%#{query_array[index]}%'"
        end
        result = data.each_with_index.map{ |item, index| index == 1 ? (' and ' + item) : item }.join
      end
      result
    end

    ...
  end
end

Prepare search hash

So, let’s split params by delimeter, and pass them to the build_hash method:

fields = params[:fields].split('|')
filters = params[:filter].split('|')
q = searcher.build_hash(fields, filters)

Searcher

This is main search class, here we are using separate connection to our DB instance to filter data, like this:

data = Searcher.new(current_user.id, q, default_search, page, sort)

The most interesting method in Searcher class called to_sql, we generating SQL statements from different fields in a loop.

In our case, we have jsonb structure like this, so no uniq keys and in same time system should provide ability for searching with multiple conditions using %LIKE% syntax in value fields.

[
  {"name": "location", "value": "KZN"},
  {"name": "ig", "value": "IG"},
  {"name": "age", "value": "24"},
  {"name": "city", "value": "KZN"},
  {"name": "team", "value": "ASR"},
  {"name": "size", "value": "8"},
  {"name": "code", "value": "SSS"}
]

So, this code related to SQL generation with json syntax :

sql << "exists (select * from jsonb_array_elements(u.custom_fields) as s(j) "
sql << "where lower(s.j ->> 'value') ilike #{"'%#{item[:value]}%'"} and lower(s.j ->> 'name') in ('#{item[:name]}'))"

Searcher class implementation.

Here we’re passing user_id, search string, page, sort and default limit per page.

We also paginating results manually, with total_items, total_pages, offset.

class Searcher
  def initialize(user_id, q, default_search, page, sort, limit = 20)
    @page = page.to_i
    @limit = limit
    @q = q
    @user_id = user_id
    @default_search = default_search
    @sort = sort
  end

  def current_page
    @page
  end

  def total_items
    res = execute(to_count_sql)
    total = res.getvalue(0, 0)
    total.to_f.ceil
  end

  def total_pages
    (total_items / @limit).ceil
  end

  def limit_value
    @limit
  end

  def offset
    (@page - 1) * @limit
  end

  def all
    execute(to_sql)
  end

  def execute(sql)
    config = ActiveRecord::Base.configurations[Rails.env] || Rails.application.config.database_configuration[Rails.env]
    host     = config["host"]
    port     = config["port"]
    database = config["database"]
    username = config["username"]
    password = config["password"]
    begin
      con = PG.connect(host, port, '', '', database, username, password)
      res = con.exec(sql)
    rescue PG::Error => e
      Rails.logger.error(e.message)
    ensure
      con.close if con
    end
    res
  end

  def to_sql
    sql = "select u.*
    from customers AS u where "
    if @q.any?
      @q.each_with_index do |item, index|
        sql << ' and ' if index != 0
        sql << "exists (select * from jsonb_array_elements(u.custom_fields) as s(j) "
        sql << "where lower(s.j ->> 'value') ilike #{"'%#{item[:value]}%'"} and lower(s.j ->> 'name') in ('#{item[:name]}'))"
      end
    end

    if @default_search && @default_search.present?
      if @q.any?
        sql << ' and ' + @default_search
      else
        sql << @default_search
      end
    end

    sql << " and user_id=#{@user_id}"
    sql << " order by #{@sort} "
    sql << " limit #{@limit} offset #{offset}"
    Rails.logger.info(sql)
    sql
  end
  ...
end

Final steps

So, we are getting IDs from Searcher class and just passing them to the Customer model to retrieve data:

data = Searcher.new(current_user.id, q, default_search, page, sort)
results = Customer.where(id: data.all.map { |x| x['id']&.to_i })