How to Build a Booking Search Engine Similar to Booking.com with Ruby on Rails 6 and ElasticSearch 7

Think building a booking system is not your cup of tea? I bet, you will change your mind after reading this.

I've made it super easy for you with every steps. Here, I'll share all about building a booking engine for a rental property like booking.com using Ruby on Rails 6 and Elasticsearch 7.

When it comes to hiring Ruby developers, Elasticsearch is one of the top technology they have to know well.

I was unsure about building a Booking engine, but here is how it went:

  • It's too hard to code; maybe takes a few years
  • But what if... I just give it a try
  • Wow, this is amazing, it works!

The funny thing is, building a working "Booking engine" was easier than designing and implementing an interface that manages availability, prices, and properties of the owners.

It's a little more complex than Airbnb. Mainly because it allows hotels to manage multiple rooms with different options with their own availability/prices.

But, here is a short and high-level specification of the project for easier understanding:

We will be building a system that searches for properties by filtering:

  • Rooms available for a given date range
  • Property options
  • Room options
  • Number of guests

In my real project, there are added requirements like geo-location search, distinct availability statuses, minimum booking nights, and more.

But in this project, I've skipped adding photos, coordinates, and others as I'd like to focus ONLY on booking search.

Ruby on Rails Models

I'll be using the Property model as data model. If you've worked on something similar, you would already know what it does. It's pretty self-explanatory.

I just decided to call it Property rather than Hotel as we have different properties such as hotels, cottages, bed&breakfast, apartments, and others.

class Property < ApplicationRecord
  include PropertyElastic

  has_many :rooms, dependent: :destroy
  has_many :booking_rooms, through: :rooms
  has_and_belongs_to_many :property_options
end

The PropertyOption model has just one field: 'title'.

In my real project, property options are grouped into predefined sets, like services, comfort levels, the closest infrastructure, and others.

The Room model represents room type in a hotel. For example, if a hotel has 10 standards for 2 guests rooms and 5 rooms for 4 guests, we'll create two Room objects for this hotel.

This model should display room types that are available in a specific hotel.

If the property is a cottage that could be rented as a whole, we'll still create one Room object to describe that cottage.

The room model is a kind of room type.

class Room < ApplicationRecord
  belongs_to :property
  has_and_belongs_to_many :room_options
  has_many :booking_rooms, dependent: :destroy
end

RoomOption model has fields: 'title' and 'guests' - representing the number of people this room can host.

Preparing models for booking

Now we'll add more models to store actual rooms and their availabilities. It's quite important as based on the availability, users can book a room. Pretty straightforward!

BookingRoom model represents a real room in a hotel. Let's create as many BookingRoom objects as real rooms of specific room types in the hotel. You can customize it as needed.

class BookingRoom < ApplicationRecord
  belongs_to :room
  has_many :booking_room_availabilities, dependent: :destroy

  enum status: %i[published draft]
end

BookingRoom has property 'title', 'capacity,' and relations as described above.

The status will allow us to enable or disable room for search and booking.

Here is BookingRoomAvailability class:

class BookingRoomAvailability < ApplicationRecord
  belongs_to :booking_room
  enum status: %i[available closed booked]

  validates_uniqueness_of :booking_room, scope: %i[day]
end

And here is the migration:

class CreateBookingRoomAvailabilities < ActiveRecord::Migration[5.2]
  def change
    create_table :booking_room_availabilities do |t|
      t.references :booking_room
      t.date :day
      t.integer :price
      t.integer :status

      t.timestamps
    end
  end
end

Important properties of this class are 'day' and 'price'. So that particular instance of this class will store price for a specific date and for a specific BookingRoom instance.

Integrating Elasticsearch

At this point, we have everything to start building the Elasticsearch index. For integrating, we'll use two gems:

gem 'elasticsearch-model', '7.1.1'
gem 'elasticsearch-rails', '7.1.1'

All the Elasticsearch logic, we'll be putting into a concern called property_elastic.rb

It contains index configuration and method to export the property as a JSON document.

require 'elasticsearch/model'

module PropertyElastic
  extend ActiveSupport::Concern

  included do
    include Elasticsearch::Model
    include Elasticsearch::Model::Callbacks

    settings index: { number_of_shards: 1 } do
      mappings dynamic: 'false' do
        indexes :id
        indexes :search_body, analyzer: 'snowball'
        indexes :property_options
        indexes :rooms, type: 'nested', properties: {
          'name' => {'type' => 'text'},
          'id' => {'type' => 'long'},
          'room_options' => {'type' => 'keyword'},
          'guests' => { 'type' => 'long'},
          'availability' => { 'type' => 'nested', properties: {
            'day' => { 'type' => 'date' },
            'id' => { 'type' => 'long' },
            'price' => {'type' => 'long'},
            'status' => { 'type' => 'keyword' },
            'booking_room_id' => { 'type' => 'long'}
          } }
        }
      end
    end
  end

  def as_indexed_json(_options = {})
    as_json(
      only: %i[id updated_at]
    ).merge({
      property_options: property_options.pluck(:id),
      search_body: title,
      rooms: availabilities,
      })
  end

  def availabilities
    if booking_rooms.any?
      booking_rooms.map do |br|
        {
          name: br.title,
          id: br.id,
          room_options: br.room.room_options.pluck(:id).uniq,
          guests: br.guests,
          availability: br.booking_room_availabilities.where("day >= ? AND day < ?", Date.today, Date.today + 6.months).map do |s|
            {day: s.day.to_s, price: s.price, status: s.status, room_id: br.id, id: s.id}
          end
        }
      end
    else
      rooms.map do |r|
        {
          name: r.title,
          id: r.id,
          room_options: r.room_options.pluck(:id).uniq,
        }
      end
    end
  end
end

Here are a few things to note about this file:

The index mapping for Property contains two levels of nesting documents. This is where we store all room prices and availabilities.

For each property, we create a document that includes nested documents for each BookingRoom that also contains an array of documents for each date that includes price, date, and availability.

First I thought it is not very optimal or would not work fast but somehow Elasticsearch does all the magic, and it works very fast. At least on a relatively small amount of data.

Method 'as_indexed_json' is a method to export documents for indexing.

As index mapping is more or less self-explanatory, it is worth mentioning how the 'availabilities' method works.

Not every property could have defined RoomAvailabilities with prices and as per my requirements, the booking engine should be able to find both available properties and those without prices but still counting RoomOptions.

This is why the 'availabilities' method returns different data for both scenarios. This allows searching either for counting Room or BookingRoom.

Now it's time to build the heart of our booking engine.

A search!

Search for properties with available rooms

To be able to search for Properties as per our requirements, we'll create a BookingSearch service:

class BookingSearch
  def self.perform(params, page = 1)
    @page = page || 1
    @per_page = params[:per_page]
    @query = params[:q]&.strip
    @params = params
    search
  end

  def self.search

    salt = @params[:salt] || "salt"

    terms = {
      sort: {
        _script: {
          script: "(doc['_id'] + '#{salt}').hashCode()",
          type: "number",
          order: "asc"
        }
      },
      query: {
        bool: {
          must: search_terms,
          filter:  {
            bool: {
              must: generate_term + generate_nested_term
            }
          }
        }
      },
      size: @per_page,
      from: (@page.to_i - 1) * @per_page
    }
    Property.__elasticsearch__.search(terms)
  end

  def self.generate_nested_term
    terms_all = []

    terms = []
    if @params[:room_options]
      @params[:room_options].split(",")&.map(&:to_i)&.each do |ro|
        terms.push({terms: { "rooms.room_options" => [ro] }})
      end
    end
    terms.push({range: { "rooms.guests" => {'gte' => @params[:guests].to_i}}})
    n = {nested: {
      path: "rooms",
      query: {
        bool: {
          must: terms
        }
      },
      inner_hits: { name: 'room' }
    }}
    terms_all.push(n)

    Date.parse(@params[:from]).upto(Date.parse(@params[:to]) - 1.day) do |d|
      terms = []
      terms.push(match: { "rooms.availability.status" => 'available' })
      terms.push(match: { "rooms.availability.day" => d.to_s })
      n = {nested: {
        path: "rooms.availability",
        query: {
          bool: {
            must: terms
          }
        },
        inner_hits: { name: d.to_s }
      }
      }
      terms_all.push(n)
    end

    terms_all
  end

  def self.generate_term
    terms = []

    if @params[:property_options].present?
      @params[:property_options].split(',').each do |lo|
        terms.push(term: { property_options: lo })
      end
    end

    terms
  end

  def self.search_terms
    match = [@query.blank? ? { match_all: {} } : { multi_match: { query: @query, fields: %w[search_body], operator: 'and' } }]
    match.push( { ids: { values: @params[:ids] } }) if @params[:ids]
    match
  end
end

Here are some of the key points in this file:

Order is done with a random salt string that comes from outside this service. In my project, I store this random string in the user's session to have a different order for different users but keep the order the same for one user.

We use a property title as a search_body so that we can search by title.

generate_nested_term

The search query looks pretty clear, except maybe the 'generate_nested_term' part.

In this method, we extend a query for search in nested documents for available rooms.

As you can notice, we can pass {skip_availability: true} into this service to skip availability search, in this case, it would be a regular search with filtering.

To find a property with available rooms, we add conditions for each date, so that for example, when users search for a date range from 21st till 27th, we'll add 5 conditions for 21st, 22nd, 23rd, 24th, 25th, and 26th dates where we expect BookingRoomAvailability is available and has status 'available'.

inner_hits

This is an important option in our search query as later we'll take data from inner hits to show search not only as property but also to show cost for entire booking per each BookingRoom. This will also allow us to know how many rooms are available and per which cost.

Simple as that! Well, I spent a lot of time building both index and search queries but still have few issues. One of the issues is that I can't skip all BookingRoomAvailabilities from the "room" inner_hits. So that in results I have documents that include prices not only for the given date range but all available dates for every room found.

Now it's time to tinker with the code! I promise you, it's going to be a lot of fun ;)

Testing

Let's create some data for testing. We can do this in a file app/services/data_demo.rb

And a logic to retrieve rich search results with Elasticsearch inner hits:

class DataDemo
  PROPERTIES = ['White', 'Blue', 'Yellow', 'Red', 'Green']
  PROPERTY_OPTIONS = ['WiFi', 'Parking', 'Swimming Pool', 'Playground']
  ROOM_OPTIONS = ['Kitchen', 'Kettle', 'Work table', 'TV']
  ROOM_TYPES = ['Standard', 'Comfort']

  def self.search
    params = {}
    from_search = Date.today + rand(20).days
    params[:from] = from_search.to_s
    params[:to] = (from_search + 3.days).to_s
    params[:per_page] = 10
    property_options = PROPERTY_OPTIONS.sample(1).map{|po| PropertyOption.find_by title: "po}"
    room_options = ROOM_OPTIONS.sample(1).map{|ro| RoomOption.find_by title: "ro}"
    params[:property_options] = property_options.map(&:id).join(',')
    params[:room_options] = room_options.map(&:id).join(',')
    params[:guests] = 2

    res = BookingSearch.perform(params)

    puts "Search for dates: #{params[:from]}..#{params[:to]}"
    puts "Property options: #{property_options.map(&:title).to_sentence}"
    puts "Room options: #{room_options.map(&:title).to_sentence}"

    res.response.hits.hits.each do |hit|

      puts "Property: #{hit._source.search_body}"
      available_rooms = {}

      # dive into inner hits to get detailed search data
      # here we transform search result into more structured way   
      hit.inner_hits.each do |key, inner_hit|
        if key != 'room'
          inner_hit.hits.hits.each do |v|
            available_rooms[v._source.room_id.to_s] ||= []
            available_rooms[v._source.room_id.to_s] << { day: v._source.day, price: v._source.price }
          end
        else
          puts "Rooms: #{inner_hit.hits.hits.count}"
        end
      end

      # printing results
      available_rooms.each do |key, ar|
        booking_room = BookingRoom.find key
        puts "Room: #{booking_room.room.title} / #{booking_room.title}"
        total_price = 0

        ar.each do |day|
          puts "#{day[:day]}: $#{day[:price]}/night"
          total_price += day[:price]
        end

        puts "Total price for #{ar.count} #{'night'.pluralize(ar.count)}: $#{total_price}"
        puts "----------------------------\n"
      end
    end

    res.response.hits["total"].value
  end

  def self.delete
    Property.destroy_all
    PropertyOption.destroy_all
    RoomOption.destroy_all
  end

  def self.run

    delete

    PROPERTY_OPTIONS.each { |po| PropertyOption.create(title: po) }
    ROOM_OPTIONS.each { |ro| RoomOption.create(title: ro) }

    5.times do |i|
      p = Property.create(title: PROPERTIES[i])
      rooms = rand(2) + 1

      p.property_options = PROPERTY_OPTIONS.sample(2).map{ |po| PropertyOption.find_by title: po }

      rooms.times do |j|
        room = p.rooms.create({title: "#{ROOM_TYPES[rand(2)]} #{j}" })
        room.room_options = ROOM_OPTIONS.sample(2).map{ |po| RoomOption.find_by title: po }

        (rand(2) + 1).times do |k|
          booking_room = room.booking_rooms.create title: "Room #{k+1}", status: :published, guests: rand(4) + 1

          30.times do |d|
            booking_room.booking_room_availabilities.create day: Date.today + d.days, status: :available, price: [100, 200, 300][rand(3)]
          end
        end
      end
    end
    Property.__elasticsearch__.delete_index!
    Property.__elasticsearch__.create_index!
    Property.__elasticsearch__.import
  end
end

I encourage you to play more with data in response to ElasticSearch.

To run this code, open Rails console with "rails c" and run:

DataDemo.run

This will generate an example data and will index it into ElasticSearch.

Then play with data by running:

DataDemo.search

Final thoughts

I enjoyed it a lot while building a booking engine for my pet project. There are tons of additional features out there, but I hope you get the main idea of how to build a booking search engine here.

Ruby on Rails and Elasticsearch are great tools to craft software.

If you're looking to hire great Ruby on Rails developers, consider contacting Reintech.

The code and an example app is available at GitHub: https://github.com/kiosan/elastic_booking

Let me know in the comments if this tutorial was helpful.

22