23
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.
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.
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.
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!
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.
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'.
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 ;)
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
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.
23