Commit 59e53938 authored by Hiroyuki Sato's avatar Hiroyuki Sato

Fuzzy search issuable title or description

parent d6e956d3
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
# #
module Issuable module Issuable
extend ActiveSupport::Concern extend ActiveSupport::Concern
include Gitlab::SQL::Pattern
include CacheMarkdownField include CacheMarkdownField
include Participable include Participable
include Mentionable include Mentionable
...@@ -122,7 +123,9 @@ module Issuable ...@@ -122,7 +123,9 @@ module Issuable
# #
# Returns an ActiveRecord::Relation. # Returns an ActiveRecord::Relation.
def search(query) def search(query)
where(arel_table[:title].matches("%#{query}%")) title = to_fuzzy_arel(:title, query)
where(title)
end end
# Searches for records with a matching title or description. # Searches for records with a matching title or description.
...@@ -133,10 +136,10 @@ module Issuable ...@@ -133,10 +136,10 @@ module Issuable
# #
# Returns an ActiveRecord::Relation. # Returns an ActiveRecord::Relation.
def full_search(query) def full_search(query)
t = arel_table title = to_fuzzy_arel(:title, query)
pattern = "%#{query}%" description = to_fuzzy_arel(:description, query)
where(t[:title].matches(pattern).or(t[:description].matches(pattern))) where(title&.or(description))
end end
def sort(method, excluded_labels: []) def sort(method, excluded_labels: [])
......
...@@ -4,6 +4,7 @@ module Gitlab ...@@ -4,6 +4,7 @@ module Gitlab
extend ActiveSupport::Concern extend ActiveSupport::Concern
MIN_CHARS_FOR_PARTIAL_MATCHING = 3 MIN_CHARS_FOR_PARTIAL_MATCHING = 3
REGEX_QUOTED_WORD = /(?<=^| )"[^"]+"(?= |$)/
class_methods do class_methods do
def to_pattern(query) def to_pattern(query)
...@@ -17,6 +18,28 @@ module Gitlab ...@@ -17,6 +18,28 @@ module Gitlab
def partial_matching?(query) def partial_matching?(query)
query.length >= MIN_CHARS_FOR_PARTIAL_MATCHING query.length >= MIN_CHARS_FOR_PARTIAL_MATCHING
end end
def to_fuzzy_arel(column, query)
words = select_fuzzy_words(query)
matches = words.map { |word| arel_table[column].matches(to_pattern(word)) }
matches.reduce { |result, match| result.and(match) }
end
def select_fuzzy_words(query)
quoted_words = query.scan(REGEX_QUOTED_WORD)
query = quoted_words.reduce(query) { |q, quoted_word| q.sub(quoted_word, '') }
words = query.split(/\s+/)
quoted_words.map! { |quoted_word| quoted_word[1..-2] }
words.concat(quoted_words)
words.select { |word| partial_matching?(word) }
end
end end
end end
end end
......
...@@ -52,4 +52,124 @@ describe Gitlab::SQL::Pattern do ...@@ -52,4 +52,124 @@ describe Gitlab::SQL::Pattern do
end end
end end
end end
describe '.select_fuzzy_words' do
subject(:select_fuzzy_words) { Issue.select_fuzzy_words(query) }
context 'with a word equal to 3 chars' do
let(:query) { 'foo' }
it 'returns array cotaining a word' do
expect(select_fuzzy_words).to match_array(['foo'])
end
end
context 'with a word shorter than 3 chars' do
let(:query) { 'fo' }
it 'returns empty array' do
expect(select_fuzzy_words).to match_array([])
end
end
context 'with two words both equal to 3 chars' do
let(:query) { 'foo baz' }
it 'returns array containing two words' do
expect(select_fuzzy_words).to match_array(%w[foo baz])
end
end
context 'with two words divided by two speces both equal to 3 chars' do
let(:query) { 'foo baz' }
it 'returns array containing two words' do
expect(select_fuzzy_words).to match_array(%w[foo baz])
end
end
context 'with two words equal to 3 chars and shorter than 3 chars' do
let(:query) { 'foo ba' }
it 'returns array containing a word' do
expect(select_fuzzy_words).to match_array(['foo'])
end
end
context 'with a multi-word surrounded by double quote' do
let(:query) { '"really bar"' }
it 'returns array containing a multi-word' do
expect(select_fuzzy_words).to match_array(['really bar'])
end
end
context 'with a multi-word surrounded by double quote and two words' do
let(:query) { 'foo "really bar" baz' }
it 'returns array containing a multi-word and tow words' do
expect(select_fuzzy_words).to match_array(['foo', 'really bar', 'baz'])
end
end
context 'with a multi-word surrounded by double quote missing a spece before the first double quote' do
let(:query) { 'foo"really bar"' }
it 'returns array containing two words with double quote' do
expect(select_fuzzy_words).to match_array(['foo"really', 'bar"'])
end
end
context 'with a multi-word surrounded by double quote missing a spece after the second double quote' do
let(:query) { '"really bar"baz' }
it 'returns array containing two words with double quote' do
expect(select_fuzzy_words).to match_array(['"really', 'bar"baz'])
end
end
context 'with two multi-word surrounded by double quote and two words' do
let(:query) { 'foo "really bar" baz "awesome feature"' }
it 'returns array containing two multi-words and tow words' do
expect(select_fuzzy_words).to match_array(['foo', 'really bar', 'baz', 'awesome feature'])
end
end
end
describe '.to_fuzzy_arel' do
subject(:to_fuzzy_arel) { Issue.to_fuzzy_arel(:title, query) }
context 'with a word equal to 3 chars' do
let(:query) { 'foo' }
it 'returns a single ILIKE condition' do
expect(to_fuzzy_arel.to_sql).to eq(%("issues"."title" ILIKE '%foo%'))
end
end
context 'with a word shorter than 3 chars' do
let(:query) { 'fo' }
it 'returns nil' do
expect(to_fuzzy_arel).to be_nil
end
end
context 'with two words both equal to 3 chars' do
let(:query) { 'foo baz' }
it 'returns a joining ILIKE condition using a AND' do
expect(to_fuzzy_arel.to_sql).to eq(%("issues"."title" ILIKE '%foo%' AND "issues"."title" ILIKE '%baz%'))
end
end
context 'with a multi-word surrounded by double quote and two words' do
let(:query) { 'foo "really bar" baz' }
it 'returns a joining ILIKE condition using a AND' do
expect(to_fuzzy_arel.to_sql).to eq(%("issues"."title" ILIKE '%foo%' AND "issues"."title" ILIKE '%baz%' AND "issues"."title" ILIKE '%really bar%'))
end
end
end
end end
...@@ -66,56 +66,76 @@ describe Issuable do ...@@ -66,56 +66,76 @@ describe Issuable do
end end
describe ".search" do describe ".search" do
let!(:searchable_issue) { create(:issue, title: "Searchable issue") } let!(:searchable_issue) { create(:issue, title: "Searchable awesome issue") }
it 'returns notes with a matching title' do it 'returns issues with a matching title' do
expect(issuable_class.search(searchable_issue.title)) expect(issuable_class.search(searchable_issue.title))
.to eq([searchable_issue]) .to eq([searchable_issue])
end end
it 'returns notes with a partially matching title' do it 'returns issues with a partially matching title' do
expect(issuable_class.search('able')).to eq([searchable_issue]) expect(issuable_class.search('able')).to eq([searchable_issue])
end end
it 'returns notes with a matching title regardless of the casing' do it 'returns issues with a matching title regardless of the casing' do
expect(issuable_class.search(searchable_issue.title.upcase)) expect(issuable_class.search(searchable_issue.title.upcase))
.to eq([searchable_issue]) .to eq([searchable_issue])
end end
it 'returns issues with a fuzzy matching title' do
expect(issuable_class.search('searchable issue')).to eq([searchable_issue])
end
it 'returns all issues with a query shorter than 3 chars' do
expect(issuable_class.search('zz')).to eq(issuable_class.all)
end
end end
describe ".full_search" do describe ".full_search" do
let!(:searchable_issue) do let!(:searchable_issue) do
create(:issue, title: "Searchable issue", description: 'kittens') create(:issue, title: "Searchable awesome issue", description: 'Many cute kittens')
end end
it 'returns notes with a matching title' do it 'returns issues with a matching title' do
expect(issuable_class.full_search(searchable_issue.title)) expect(issuable_class.full_search(searchable_issue.title))
.to eq([searchable_issue]) .to eq([searchable_issue])
end end
it 'returns notes with a partially matching title' do it 'returns issues with a partially matching title' do
expect(issuable_class.full_search('able')).to eq([searchable_issue]) expect(issuable_class.full_search('able')).to eq([searchable_issue])
end end
it 'returns notes with a matching title regardless of the casing' do it 'returns issues with a matching title regardless of the casing' do
expect(issuable_class.full_search(searchable_issue.title.upcase)) expect(issuable_class.full_search(searchable_issue.title.upcase))
.to eq([searchable_issue]) .to eq([searchable_issue])
end end
it 'returns notes with a matching description' do it 'returns issues with a fuzzy matching title' do
expect(issuable_class.full_search('searchable issue')).to eq([searchable_issue])
end
it 'returns issues with a matching description' do
expect(issuable_class.full_search(searchable_issue.description)) expect(issuable_class.full_search(searchable_issue.description))
.to eq([searchable_issue]) .to eq([searchable_issue])
end end
it 'returns notes with a partially matching description' do it 'returns issues with a partially matching description' do
expect(issuable_class.full_search(searchable_issue.description)) expect(issuable_class.full_search(searchable_issue.description))
.to eq([searchable_issue]) .to eq([searchable_issue])
end end
it 'returns notes with a matching description regardless of the casing' do it 'returns issues with a matching description regardless of the casing' do
expect(issuable_class.full_search(searchable_issue.description.upcase)) expect(issuable_class.full_search(searchable_issue.description.upcase))
.to eq([searchable_issue]) .to eq([searchable_issue])
end end
it 'returns issues with a fuzzy matching description' do
expect(issuable_class.full_search('many kittens')).to eq([searchable_issue])
end
it 'returns all issues with a query shorter than 3 chars' do
expect(issuable_class.search('zz')).to eq(issuable_class.all)
end
end end
describe '.to_ability_name' do describe '.to_ability_name' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment