@@ -64,6 +64,7 @@ gem 'nsa', '~> 0.2' | |||||
gem 'oj', '~> 3.8' | gem 'oj', '~> 3.8' | ||||
gem 'ostatus2', '~> 2.0' | gem 'ostatus2', '~> 2.0' | ||||
gem 'ox', '~> 2.11' | gem 'ox', '~> 2.11' | ||||
gem 'parslet' | |||||
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c' | gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c' | ||||
gem 'pundit', '~> 2.0' | gem 'pundit', '~> 2.0' | ||||
gem 'premailer-rails' | gem 'premailer-rails' | ||||
@@ -404,6 +404,7 @@ GEM | |||||
parallel | parallel | ||||
parser (2.6.3.0) | parser (2.6.3.0) | ||||
ast (~> 2.4.0) | ast (~> 2.4.0) | ||||
parslet (1.8.2) | |||||
pastel (0.7.2) | pastel (0.7.2) | ||||
equatable (~> 0.5.0) | equatable (~> 0.5.0) | ||||
tty-color (~> 0.4.0) | tty-color (~> 0.4.0) | ||||
@@ -724,6 +725,7 @@ DEPENDENCIES | |||||
paperclip (~> 6.0) | paperclip (~> 6.0) | ||||
paperclip-av-transcoder (~> 0.6) | paperclip-av-transcoder (~> 0.6) | ||||
parallel_tests (~> 2.29) | parallel_tests (~> 2.29) | ||||
parslet | |||||
pg (~> 1.1) | pg (~> 1.1) | ||||
pghero (~> 2.2) | pghero (~> 2.2) | ||||
pkg-config (~> 1.3) | pkg-config (~> 1.3) | ||||
@@ -0,0 +1,14 @@ | |||||
# frozen_string_literal: true | |||||
class SearchQueryParser < Parslet::Parser | |||||
rule(:term) { match('[^\s":]').repeat(1).as(:term) } | |||||
rule(:quote) { str('"') } | |||||
rule(:colon) { str(':') } | |||||
rule(:space) { match('\s').repeat(1) } | |||||
rule(:operator) { (str('+') | str('-')).as(:operator) } | |||||
rule(:prefix) { (term >> colon).as(:prefix) } | |||||
rule(:phrase) { (quote >> (term >> space.maybe).repeat >> quote).as(:phrase) } | |||||
rule(:clause) { (prefix.maybe >> operator.maybe >> (phrase | term)).as(:clause) } | |||||
rule(:query) { (clause >> space.maybe).repeat.as(:query) } | |||||
root(:query) | |||||
end |
@@ -0,0 +1,86 @@ | |||||
# frozen_string_literal: true | |||||
class SearchQueryTransformer < Parslet::Transform | |||||
class Query | |||||
attr_reader :should_clauses, :must_not_clauses, :must_clauses | |||||
def initialize(clauses) | |||||
grouped = clauses.chunk(&:operator).to_h | |||||
@should_clauses = grouped.fetch(:should, []) | |||||
@must_not_clauses = grouped.fetch(:must_not, []) | |||||
@must_clauses = grouped.fetch(:must, []) | |||||
end | |||||
def apply(search) | |||||
should_clauses.each { |clause| search = search.query.should(clause_to_query(clause)) } | |||||
must_clauses.each { |clause| search = search.query.must(clause_to_query(clause)) } | |||||
must_not_clauses.each { |clause| search = search.query.must_not(clause_to_query(clause)) } | |||||
search.query.minimum_should_match(1) | |||||
end | |||||
private | |||||
def clause_to_query(clause) | |||||
case clause | |||||
when TermClause | |||||
{ multi_match: { type: 'most_fields', query: clause.term, fields: ['text', 'text.stemmed'] } } | |||||
when PhraseClause | |||||
{ match_phrase: { text: { query: clause.phrase } } } | |||||
else | |||||
raise "Unexpected clause type: #{clause}" | |||||
end | |||||
end | |||||
end | |||||
class Operator | |||||
class << self | |||||
def symbol(str) | |||||
case str | |||||
when '+' | |||||
:must | |||||
when '-' | |||||
:must_not | |||||
when nil | |||||
:should | |||||
else | |||||
raise "Unknown operator: #{str}" | |||||
end | |||||
end | |||||
end | |||||
end | |||||
class TermClause | |||||
attr_reader :prefix, :operator, :term | |||||
def initialize(prefix, operator, term) | |||||
@prefix = prefix | |||||
@operator = Operator.symbol(operator) | |||||
@term = term | |||||
end | |||||
end | |||||
class PhraseClause | |||||
attr_reader :prefix, :operator, :phrase | |||||
def initialize(prefix, operator, phrase) | |||||
@prefix = prefix | |||||
@operator = Operator.symbol(operator) | |||||
@phrase = phrase | |||||
end | |||||
end | |||||
rule(clause: subtree(:clause)) do | |||||
prefix = clause[:prefix][:term].to_s if clause[:prefix] | |||||
operator = clause[:operator]&.to_s | |||||
if clause[:term] | |||||
TermClause.new(prefix, operator, clause[:term].to_s) | |||||
elsif clause[:phrase] | |||||
PhraseClause.new(prefix, operator, clause[:phrase].map { |p| p[:term].to_s }.join(' ')) | |||||
else | |||||
raise "Unexpected clause type: #{clause}" | |||||
end | |||||
end | |||||
rule(query: sequence(:clauses)) { Query.new(clauses) } | |||||
end |
@@ -33,8 +33,7 @@ class SearchService < BaseService | |||||
end | end | ||||
def perform_statuses_search! | def perform_statuses_search! | ||||
definition = StatusesIndex.filter(term: { searchable_by: @account.id }) | |||||
.query(multi_match: { type: 'most_fields', query: @query, operator: 'and', fields: %w(text text.stemmed) }) | |||||
definition = parsed_query.apply(StatusesIndex.filter(term: { searchable_by: @account.id })) | |||||
if @options[:account_id].present? | if @options[:account_id].present? | ||||
definition = definition.filter(term: { account_id: @options[:account_id] }) | definition = definition.filter(term: { account_id: @options[:account_id] }) | ||||
@@ -70,7 +69,7 @@ class SearchService < BaseService | |||||
end | end | ||||
def url_query? | def url_query? | ||||
@options[:type].blank? && @query =~ /\Ahttps?:\/\// | |||||
@resolve && @options[:type].blank? && @query =~ /\Ahttps?:\/\// | |||||
end | end | ||||
def url_resource_results | def url_resource_results | ||||
@@ -120,4 +119,8 @@ class SearchService < BaseService | |||||
domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id), | domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id), | ||||
} | } | ||||
end | end | ||||
def parsed_query | |||||
SearchQueryTransformer.new.apply(SearchQueryParser.new.parse(@query)) | |||||
end | |||||
end | end |
@@ -27,7 +27,7 @@ describe SearchService, type: :service do | |||||
it 'returns the empty results' do | it 'returns the empty results' do | ||||
service = double(call: nil) | service = double(call: nil) | ||||
allow(ResolveURLService).to receive(:new).and_return(service) | allow(ResolveURLService).to receive(:new).and_return(service) | ||||
results = subject.call(@query, nil, 10) | |||||
results = subject.call(@query, nil, 10, resolve: true) | |||||
expect(service).to have_received(:call).with(@query, on_behalf_of: nil) | expect(service).to have_received(:call).with(@query, on_behalf_of: nil) | ||||
expect(results).to eq empty_results | expect(results).to eq empty_results | ||||
@@ -40,7 +40,7 @@ describe SearchService, type: :service do | |||||
service = double(call: account) | service = double(call: account) | ||||
allow(ResolveURLService).to receive(:new).and_return(service) | allow(ResolveURLService).to receive(:new).and_return(service) | ||||
results = subject.call(@query, nil, 10) | |||||
results = subject.call(@query, nil, 10, resolve: true) | |||||
expect(service).to have_received(:call).with(@query, on_behalf_of: nil) | expect(service).to have_received(:call).with(@query, on_behalf_of: nil) | ||||
expect(results).to eq empty_results.merge(accounts: [account]) | expect(results).to eq empty_results.merge(accounts: [account]) | ||||
end | end | ||||
@@ -52,7 +52,7 @@ describe SearchService, type: :service do | |||||
service = double(call: status) | service = double(call: status) | ||||
allow(ResolveURLService).to receive(:new).and_return(service) | allow(ResolveURLService).to receive(:new).and_return(service) | ||||
results = subject.call(@query, nil, 10) | |||||
results = subject.call(@query, nil, 10, resolve: true) | |||||
expect(service).to have_received(:call).with(@query, on_behalf_of: nil) | expect(service).to have_received(:call).with(@query, on_behalf_of: nil) | ||||
expect(results).to eq empty_results.merge(statuses: [status]) | expect(results).to eq empty_results.merge(statuses: [status]) | ||||
end | end | ||||