Fuzzy search issuable title or description
This commit is contained in:
parent
d6e956d3a8
commit
59e5393827
4 changed files with 181 additions and 15 deletions
|
@ -6,6 +6,7 @@
|
|||
#
|
||||
module Issuable
|
||||
extend ActiveSupport::Concern
|
||||
include Gitlab::SQL::Pattern
|
||||
include CacheMarkdownField
|
||||
include Participable
|
||||
include Mentionable
|
||||
|
@ -122,7 +123,9 @@ module Issuable
|
|||
#
|
||||
# Returns an ActiveRecord::Relation.
|
||||
def search(query)
|
||||
where(arel_table[:title].matches("%#{query}%"))
|
||||
title = to_fuzzy_arel(:title, query)
|
||||
|
||||
where(title)
|
||||
end
|
||||
|
||||
# Searches for records with a matching title or description.
|
||||
|
@ -133,10 +136,10 @@ module Issuable
|
|||
#
|
||||
# Returns an ActiveRecord::Relation.
|
||||
def full_search(query)
|
||||
t = arel_table
|
||||
pattern = "%#{query}%"
|
||||
title = to_fuzzy_arel(:title, query)
|
||||
description = to_fuzzy_arel(:description, query)
|
||||
|
||||
where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
|
||||
where(title&.or(description))
|
||||
end
|
||||
|
||||
def sort(method, excluded_labels: [])
|
||||
|
|
|
@ -4,6 +4,7 @@ module Gitlab
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
MIN_CHARS_FOR_PARTIAL_MATCHING = 3
|
||||
REGEX_QUOTED_WORD = /(?<=^| )"[^"]+"(?= |$)/
|
||||
|
||||
class_methods do
|
||||
def to_pattern(query)
|
||||
|
@ -17,6 +18,28 @@ module Gitlab
|
|||
def partial_matching?(query)
|
||||
query.length >= MIN_CHARS_FOR_PARTIAL_MATCHING
|
||||
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
|
||||
|
|
|
@ -52,4 +52,124 @@ describe Gitlab::SQL::Pattern do
|
|||
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
|
||||
|
|
|
@ -66,56 +66,76 @@ describe Issuable do
|
|||
end
|
||||
|
||||
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))
|
||||
.to eq([searchable_issue])
|
||||
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])
|
||||
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))
|
||||
.to eq([searchable_issue])
|
||||
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
|
||||
|
||||
describe ".full_search" do
|
||||
let!(:searchable_issue) do
|
||||
create(:issue, title: "Searchable issue", description: 'kittens')
|
||||
create(:issue, title: "Searchable awesome issue", description: 'Many cute kittens')
|
||||
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))
|
||||
.to eq([searchable_issue])
|
||||
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])
|
||||
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))
|
||||
.to eq([searchable_issue])
|
||||
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))
|
||||
.to eq([searchable_issue])
|
||||
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))
|
||||
.to eq([searchable_issue])
|
||||
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))
|
||||
.to eq([searchable_issue])
|
||||
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
|
||||
|
||||
describe '.to_ability_name' do
|
||||
|
|
Loading…
Reference in a new issue