Skip to content

Commit 2e8d42b

Browse files
authored
feat: full text search examples (#2)
* fts examples * action file changes
1 parent 4a5d564 commit 2e8d42b

File tree

7 files changed

+426
-3
lines changed

7 files changed

+426
-3
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ jobs:
1212
run_tests:
1313
name: Run Tests
1414
runs-on: ubuntu-latest
15+
if: github.event_name == 'schedule' || github.event_name == 'push' || github.event.pull_request.merged == false
1516
env:
1617
DB_CONN_STR: ${{ vars.DB_CONN_STR }}
1718
DB_USERNAME: ${{ vars.DB_USERNAME }}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
module V1
5+
class HotelsController < ApplicationController
6+
skip_before_action :verify_authenticity_token, only: %i[search filter]
7+
before_action :validate_query_params, only: [:search]
8+
# GET /api/v1/hotels/autocomplete
9+
def search
10+
@hotels = HotelSearch.search_name(params[:name])
11+
render json: @hotels, status: :ok
12+
rescue StandardError => e
13+
render json: { error: 'Internal server error', message: e.message }, status: :internal_server_error
14+
end
15+
16+
# GET /api/v1/hotels/filter
17+
def filter
18+
@hotels = HotelSearch.filter(HotelSearch.new(
19+
{
20+
"name"=> hotel_search_params[:name],
21+
"title" => hotel_search_params[:title],
22+
"description" => hotel_search_params[:description],
23+
"country" => hotel_search_params[:country],
24+
"city" => hotel_search_params[:city],
25+
"state" => hotel_search_params[:state]
26+
}
27+
), hotel_search_params[:offset], hotel_search_params[:limit])
28+
render json: @hotels, status: :ok
29+
rescue StandardError => e
30+
render json: { error: 'Internal server error', message: e.message }, status: :internal_server_error
31+
end
32+
33+
def hotel_search_params
34+
params.require(:hotel).permit(:name, :title, :description, :country, :city, :state, :offset, :limit)
35+
end
36+
37+
def validate_query_params
38+
unless params[:name].present?
39+
render json: { error: "name query parameter is required" }, status: :bad_request
40+
end
41+
end
42+
end
43+
end
44+
end
45+

app/models/hotel_search.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# frozen_string_literal: true
2+
3+
class HotelSearch
4+
attr_accessor :name, :title, :description, :country, :city, :state
5+
6+
def initialize(attributes)
7+
@name = attributes['name']
8+
@title = attributes['title']
9+
@description = attributes['description']
10+
@country = attributes['country']
11+
@city = attributes['city']
12+
@state = attributes['state']
13+
end
14+
15+
def self.search_name(name)
16+
request = Couchbase::SearchRequest.new(
17+
Couchbase::SearchQuery.match(name) {|obj| obj.field = "name"}
18+
)
19+
options = Couchbase::Options::Search.new
20+
options.limit = 50
21+
options.fields = ["name"]
22+
result = INVENTORY_SCOPE.search(INDEX_NAME, request, options)
23+
result.rows.map do |row|
24+
row.fields["name"]
25+
end
26+
end
27+
28+
def self.filter(hotel_search, offset, limit)
29+
query = Couchbase::SearchQuery.conjuncts()
30+
31+
query.and_also(
32+
Couchbase::SearchQuery.term(hotel_search.name) {|obj| obj.field = "name_keyword"}
33+
) if hotel_search.name
34+
35+
query.and_also(
36+
Couchbase::SearchQuery.match(hotel_search.title) {|obj| obj.field = "title"}
37+
) if hotel_search.title
38+
39+
query.and_also(
40+
Couchbase::SearchQuery.match(hotel_search.description) {|obj| obj.field = "description"}
41+
) if hotel_search.description
42+
43+
query.and_also(
44+
Couchbase::SearchQuery.match(hotel_search.country) {|obj| obj.field = "country"}
45+
) if hotel_search.country
46+
47+
query.and_also(
48+
Couchbase::SearchQuery.match(hotel_search.state) {|obj| obj.field = "state"}
49+
) if hotel_search.state
50+
51+
query.and_also(
52+
Couchbase::SearchQuery.match(hotel_search.city) {|obj| obj.field = "city"}
53+
) if hotel_search.city
54+
55+
request = Couchbase::SearchRequest.new(query)
56+
57+
options = Couchbase::Options::Search.new
58+
options.skip = 0
59+
options.limit = 50
60+
options.skip = offset if offset
61+
options.limit= limit if limit
62+
options.fields = ["*"]
63+
options.sort = ["-_score", "name_keyword"]
64+
65+
result = INVENTORY_SCOPE.search(INDEX_NAME, request, options)
66+
result.rows.map do |row|
67+
new(row.fields)
68+
end
69+
end
70+
end
71+

config/initializers/couchbase.rb

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,37 @@
4747
scope = bucket.scope('inventory')
4848
end
4949

50+
begin
51+
# create hotel search index
52+
index_file_path = 'hotel_search_index.json'
53+
index_content = File.read(index_file_path)
54+
index_data = JSON.parse(index_content)
55+
name = index_data["name"]
56+
index = Couchbase::Management::SearchIndex.new
57+
index.name= index_data["name"]
58+
index.type= index_data["type"]
59+
index.uuid= index_data["uuid"] if index_data.has_key?("uuid")
60+
index.params= index_data["params"] if index_data.has_key?("params")
61+
index.source_name= index_data["sourceName"] if index_data.has_key?("sourceName")
62+
index.source_type= index_data["sourceType"] if index_data.has_key?("sourceType")
63+
index.source_uuid= index_data["sourceUUID"] if index_data.has_key?("sourceUUID")
64+
index.source_params= index_data["sourceParams"] if index_data.has_key?("sourceParams")
65+
index.plan_params= index_data["planParams"] if index_data.has_key?("planParams")
66+
scope.search_indexes.upsert_index(index)
67+
rescue StandardError => err
68+
#puts err.full_message
69+
end
70+
5071
%w[airline airport route].each do |collection_name|
5172
scope.collection(collection_name)
5273
rescue Couchbase::Error::CollectionNotFoundError
5374
scope.create_collection(collection_name)
5475
end
5576

56-
AIRLINE_COLLECTION = scope.collection('airline')
57-
AIRPORT_COLLECTION = scope.collection('airport')
58-
ROUTE_COLLECTION = scope.collection('route')
77+
# Scope is declared as constant to run FTS queries
78+
INVENTORY_SCOPE = scope
79+
INDEX_NAME = name
80+
AIRLINE_COLLECTION = INVENTORY_SCOPE.collection('airline')
81+
AIRPORT_COLLECTION = INVENTORY_SCOPE.collection('airport')
82+
ROUTE_COLLECTION = INVENTORY_SCOPE.collection('route')
83+
HOTEL_COLLECTION = INVENTORY_SCOPE.collection('hotel')

config/routes.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
post 'routes/:id', to: 'routes#create'
3636
put 'routes/:id', to: 'routes#update'
3737
delete 'routes/:id', to: 'routes#destroy'
38+
39+
#Hotel resource routes
40+
get 'hotels/autocomplete', to: 'hotels#search'
41+
post 'hotels/filter', to: 'hotels#filter'
3842
end
3943
end
4044
end

hotel_search_index.json

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
{
2+
"name": "hotel_search",
3+
"type": "fulltext-index",
4+
"sourceType": "gocbcore",
5+
"sourceName": "travel-sample",
6+
"planParams": {
7+
"indexPartitions": 1,
8+
"numReplicas": 0
9+
},
10+
"params": {
11+
"doc_config": {
12+
"docid_prefix_delim": "",
13+
"docid_regexp": "",
14+
"mode": "scope.collection.type_field",
15+
"type_field": "type"
16+
},
17+
"mapping": {
18+
"analysis": {
19+
"analyzers": {
20+
"edge_ngram": {
21+
"token_filters": [
22+
"to_lower",
23+
"edge_ngram_2_8"
24+
],
25+
"tokenizer": "unicode",
26+
"type": "custom"
27+
}
28+
},
29+
"token_filters": {
30+
"edge_ngram_2_8": {
31+
"back": false,
32+
"max": 8,
33+
"min": 2,
34+
"type": "edge_ngram"
35+
}
36+
}
37+
},
38+
"default_analyzer": "standard",
39+
"default_datetime_parser": "dateTimeOptional",
40+
"index_dynamic": false,
41+
"store_dynamic": false,
42+
"default_mapping": {
43+
"dynamic": true,
44+
"enabled": false
45+
},
46+
"types": {
47+
"inventory.hotel": {
48+
"dynamic": false,
49+
"enabled": true,
50+
"properties": {
51+
"title": {
52+
"enabled": true,
53+
"fields":[
54+
{
55+
"docvalues": true,
56+
"include_in_all": false,
57+
"include_term_vectors": false,
58+
"index": true,
59+
"name": "title",
60+
"store": true,
61+
"type": "text"
62+
}
63+
]
64+
},
65+
"name": {
66+
"enabled": true,
67+
"fields":[
68+
{
69+
"docvalues": true,
70+
"include_in_all": false,
71+
"include_term_vectors": false,
72+
"index": true,
73+
"name": "name",
74+
"store": true,
75+
"type": "text",
76+
"analyzer": "edge_ngram"
77+
},
78+
{
79+
"docvalues": true,
80+
"include_in_all": false,
81+
"include_term_vectors": false,
82+
"index": true,
83+
"name": "name_keyword",
84+
"store": false,
85+
"type": "text",
86+
"analyzer": "keyword"
87+
}
88+
]
89+
},
90+
"description": {
91+
"enabled": true,
92+
"fields":[
93+
{
94+
"docvalues": true,
95+
"include_in_all": false,
96+
"include_term_vectors": false,
97+
"index": true,
98+
"name": "description",
99+
"store": true,
100+
"type": "text",
101+
"analyzer": "en"
102+
}
103+
]
104+
},
105+
"city": {
106+
"enabled": true,
107+
"fields":[
108+
{
109+
"docvalues": true,
110+
"include_in_all": false,
111+
"include_term_vectors": false,
112+
"index": true,
113+
"name": "city",
114+
"store": true,
115+
"type": "text",
116+
"analyzer": "keyword"
117+
}
118+
]
119+
},
120+
"state": {
121+
"enabled": true,
122+
"fields":[
123+
{
124+
"docvalues": true,
125+
"include_in_all": false,
126+
"include_term_vectors": false,
127+
"index": true,
128+
"name": "state",
129+
"store": true,
130+
"type": "text",
131+
"analyzer": "keyword"
132+
}
133+
]
134+
},
135+
"country": {
136+
"enabled": true,
137+
"fields":[
138+
{
139+
"docvalues": true,
140+
"include_in_all": false,
141+
"include_term_vectors": false,
142+
"index": true,
143+
"name": "country",
144+
"store": true,
145+
"type": "text",
146+
"analyzer": "keyword"
147+
}
148+
]
149+
}
150+
}
151+
}
152+
}
153+
}
154+
}
155+
}

0 commit comments

Comments
 (0)