full_search 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +115 -9
- data/lib/full_search/callbacks.rb +26 -8
- data/lib/full_search/config.rb +2 -2
- data/lib/full_search/distance.rb +33 -0
- data/lib/full_search/dsl.rb +29 -12
- data/lib/full_search/errors.rb +2 -0
- data/lib/full_search/exact_match.rb +7 -4
- data/lib/full_search/highlighter.rb +175 -80
- data/lib/full_search/index.rb +134 -44
- data/lib/full_search/model.rb +23 -4
- data/lib/full_search/multi_search.rb +4 -1
- data/lib/full_search/optimize_job.rb +13 -0
- data/lib/full_search/query_parser.rb +101 -88
- data/lib/full_search/quoting.rb +11 -0
- data/lib/full_search/railtie.rb +2 -8
- data/lib/full_search/search.rb +161 -30
- data/lib/full_search/soft_delete.rb +15 -2
- data/lib/full_search/typo.rb +10 -8
- data/lib/full_search/version.rb +1 -1
- data/lib/full_search.rb +16 -1
- data/lib/generators/full_search/install/templates/full_search.rb +1 -1
- data/lib/tasks/full_search.rake +50 -14
- metadata +55 -12
data/lib/full_search/model.rb
CHANGED
|
@@ -5,7 +5,7 @@ module FullSearch
|
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
6
|
|
|
7
7
|
class_methods do
|
|
8
|
-
def full_search(query_or_options = nil, filters: {}, include_soft_deleted: false, limit: nil, offset: nil, highlight: false, highlight_fields: false, &block)
|
|
8
|
+
def full_search(query_or_options = nil, filters: {}, include_soft_deleted: false, limit: nil, offset: nil, highlight: false, highlight_fields: false, matching_strategy: nil, &block)
|
|
9
9
|
if block_given? || query_or_options.is_a?(Hash)
|
|
10
10
|
@full_search_dsl ||= FullSearch::Dsl.new(self)
|
|
11
11
|
@full_search_dsl.tokenize(query_or_options[:tokenize]) if query_or_options.is_a?(Hash) && query_or_options.key?(:tokenize)
|
|
@@ -13,10 +13,11 @@ module FullSearch
|
|
|
13
13
|
FullSearch::Index.ensure_table!(self)
|
|
14
14
|
FullSearch::Callbacks.install!(self)
|
|
15
15
|
include InstanceMethods
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
FullSearch.register_model(self)
|
|
17
18
|
@full_search_dsl
|
|
18
19
|
else
|
|
19
|
-
FullSearch::Search.new(self, query_or_options, filters: filters, include_soft_deleted: include_soft_deleted, limit: limit, offset: offset, highlight: highlight, highlight_fields: highlight_fields).relation
|
|
20
|
+
FullSearch::Search.new(self, query_or_options, filters: filters, include_soft_deleted: include_soft_deleted, limit: limit, offset: offset, highlight: highlight, highlight_fields: highlight_fields, matching_strategy: matching_strategy).relation
|
|
20
21
|
end
|
|
21
22
|
end
|
|
22
23
|
|
|
@@ -27,6 +28,24 @@ module FullSearch
|
|
|
27
28
|
def full_search_ids(query, filters: {}, include_soft_deleted: false, limit: 1000)
|
|
28
29
|
full_search(query, filters: filters, include_soft_deleted: include_soft_deleted, limit: limit).pluck(:id)
|
|
29
30
|
end
|
|
31
|
+
|
|
32
|
+
def rebuild!
|
|
33
|
+
FullSearch::Index.rebuild!(self)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def optimize!
|
|
37
|
+
FullSearch::Index.optimize!(self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reindex!
|
|
41
|
+
FullSearch::Index.reindex_source_fields!(self)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
included do |base|
|
|
46
|
+
unless base.respond_to?(:search)
|
|
47
|
+
base.singleton_class.alias_method :search, :full_search
|
|
48
|
+
end
|
|
30
49
|
end
|
|
31
50
|
|
|
32
51
|
module InstanceMethods
|
|
@@ -34,7 +53,7 @@ module FullSearch
|
|
|
34
53
|
|
|
35
54
|
def full_search_text_for(field_name)
|
|
36
55
|
dsl = self.class.full_search_dsl
|
|
37
|
-
field = dsl.fields.find { |f| f.name == field_name.to_s }
|
|
56
|
+
field = dsl.fields.find { |f| f.name == field_name.to_s || f.as == field_name.to_s }
|
|
38
57
|
return nil unless field
|
|
39
58
|
|
|
40
59
|
field.source ? instance_exec(&field.source) : public_send(field.name)
|
|
@@ -14,6 +14,8 @@ module FullSearch
|
|
|
14
14
|
def call
|
|
15
15
|
searched = groups.map do |group|
|
|
16
16
|
model = fetch(group, :model)
|
|
17
|
+
raise FullSearch::NotConfiguredError, "#{model} is not full_search configured" unless model.full_search_dsl
|
|
18
|
+
|
|
17
19
|
filters = group[:filters] || {}
|
|
18
20
|
limit = positive_integer(group[:limit], 8)
|
|
19
21
|
offset = positive_integer(group[:offset], 0)
|
|
@@ -24,6 +26,7 @@ module FullSearch
|
|
|
24
26
|
filters: filters,
|
|
25
27
|
limit: raw_limit,
|
|
26
28
|
offset: offset,
|
|
29
|
+
highlight: group[:highlight],
|
|
27
30
|
highlight_fields: group[:highlight_fields]
|
|
28
31
|
)
|
|
29
32
|
|
|
@@ -40,7 +43,7 @@ module FullSearch
|
|
|
40
43
|
)
|
|
41
44
|
end
|
|
42
45
|
|
|
43
|
-
{
|
|
46
|
+
{groups: searched, total_count: searched.sum { |g| g[:total_count] }}
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
private
|
|
@@ -6,121 +6,134 @@ module FullSearch
|
|
|
6
6
|
module QueryParser
|
|
7
7
|
Token = Data.define(:type, :value)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
tokens = tokenize(query.to_s.strip)
|
|
11
|
-
return [] if tokens.empty?
|
|
9
|
+
MAX_QUERY_LENGTH = 255
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
class << self
|
|
12
|
+
def parse(query)
|
|
13
|
+
query = query.to_s.strip
|
|
14
|
+
validate!(query)
|
|
15
|
+
tokens = tokenize(query)
|
|
16
|
+
return [] if tokens.empty?
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
or_clauses = split_or(tokens)
|
|
19
|
+
|
|
20
|
+
if or_clauses.size == 1
|
|
21
|
+
parse_and_clause(or_clauses.first)
|
|
22
|
+
else
|
|
23
|
+
[:or, or_clauses.map { |clause| parse_and_clause(clause) }]
|
|
24
|
+
end
|
|
19
25
|
end
|
|
20
|
-
end
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
def to_match_expression(parsed)
|
|
28
|
+
return '""' if parsed.empty?
|
|
29
|
+
|
|
30
|
+
type, value = parsed
|
|
31
|
+
case type
|
|
32
|
+
when :and
|
|
33
|
+
build_and_expression(value)
|
|
34
|
+
when :or
|
|
35
|
+
value.map { |sub| to_match_expression(sub) }.join(" OR ")
|
|
36
|
+
else
|
|
37
|
+
node_to_match(parsed)
|
|
38
|
+
end
|
|
33
39
|
end
|
|
34
|
-
end
|
|
35
40
|
|
|
36
|
-
|
|
41
|
+
private
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
def build_and_expression(nodes)
|
|
44
|
+
non_excludes = nodes.reject { |n| n.first == :exclude }
|
|
45
|
+
excludes = nodes.select { |n| n.first == :exclude }
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
parts = non_excludes.map { |n| node_to_match(n) }
|
|
48
|
+
result = (parts.size == 1) ? parts.first : "(#{parts.join(" AND ")})"
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
excludes.each do |node|
|
|
51
|
+
_, value = node
|
|
52
|
+
result = "#{result} NOT \"#{escape(value)}\""
|
|
53
|
+
end
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
result
|
|
56
|
+
end
|
|
52
57
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
def node_to_match(node)
|
|
59
|
+
type, value = node
|
|
60
|
+
case type
|
|
61
|
+
when :term
|
|
62
|
+
'"' + escape(value) + '"*'
|
|
63
|
+
when :phrase
|
|
64
|
+
'"' + escape(value) + '"'
|
|
65
|
+
when :exclude
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
62
68
|
end
|
|
63
|
-
end
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
def validate!(query)
|
|
71
|
+
if query.bytesize > MAX_QUERY_LENGTH
|
|
72
|
+
raise InvalidQueryError, "Query too long (max #{MAX_QUERY_LENGTH} chars)"
|
|
73
|
+
end
|
|
74
|
+
raise InvalidQueryError, "Query contains invalid characters" if query.include?("\x00")
|
|
75
|
+
end
|
|
68
76
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
def tokenize(query)
|
|
78
|
+
tokens = []
|
|
79
|
+
scanner = StringScanner.new(query)
|
|
72
80
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if
|
|
76
|
-
|
|
81
|
+
until scanner.eos?
|
|
82
|
+
scanner.skip(/\s+/)
|
|
83
|
+
break if scanner.eos?
|
|
84
|
+
|
|
85
|
+
if scanner.scan('"')
|
|
86
|
+
phrase = scanner.scan_until(/"/)
|
|
87
|
+
if phrase
|
|
88
|
+
tokens << Token.new(:phrase, phrase.chomp('"'))
|
|
89
|
+
else
|
|
90
|
+
tokens << Token.new(:term, scanner.rest)
|
|
91
|
+
scanner.terminate
|
|
92
|
+
end
|
|
93
|
+
elsif scanner.scan("-")
|
|
94
|
+
scanner.skip(/\s+/)
|
|
95
|
+
tokens << Token.new(:exclude, scanner.scan(/\S+/))
|
|
96
|
+
elsif scanner.scan(/OR|or/i)
|
|
97
|
+
tokens << Token.new(:or, nil)
|
|
77
98
|
else
|
|
78
|
-
tokens << Token.new(:term, scanner.
|
|
79
|
-
scanner.terminate
|
|
99
|
+
tokens << Token.new(:term, scanner.scan(/\S+/))
|
|
80
100
|
end
|
|
81
|
-
elsif scanner.scan(/-/)
|
|
82
|
-
scanner.skip(/\s+/)
|
|
83
|
-
tokens << Token.new(:exclude, scanner.scan(/\S+/))
|
|
84
|
-
elsif scanner.scan(/OR|or/i)
|
|
85
|
-
tokens << Token.new(:or, nil)
|
|
86
|
-
else
|
|
87
|
-
tokens << Token.new(:term, scanner.scan(/\S+/))
|
|
88
101
|
end
|
|
89
|
-
end
|
|
90
102
|
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
tokens
|
|
104
|
+
end
|
|
93
105
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
106
|
+
def split_or(tokens)
|
|
107
|
+
clauses = []
|
|
108
|
+
current = []
|
|
97
109
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
110
|
+
tokens.each do |token|
|
|
111
|
+
if token.type == :or
|
|
112
|
+
clauses << current unless current.empty?
|
|
113
|
+
current = []
|
|
114
|
+
else
|
|
115
|
+
current << token
|
|
116
|
+
end
|
|
104
117
|
end
|
|
118
|
+
clauses << current unless current.empty?
|
|
119
|
+
clauses
|
|
105
120
|
end
|
|
106
|
-
clauses << current unless current.empty?
|
|
107
|
-
clauses
|
|
108
|
-
end
|
|
109
121
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
def parse_and_clause(tokens)
|
|
123
|
+
nodes = tokens.map do |token|
|
|
124
|
+
case token.type
|
|
125
|
+
when :term then [:term, token.value]
|
|
126
|
+
when :phrase then [:phrase, token.value]
|
|
127
|
+
when :exclude then [:exclude, token.value]
|
|
128
|
+
end
|
|
116
129
|
end
|
|
117
|
-
end
|
|
118
130
|
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
(nodes.size == 1) ? nodes.first : [:and, nodes]
|
|
132
|
+
end
|
|
121
133
|
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
def escape(value)
|
|
135
|
+
value.to_s.gsub('"', '""')
|
|
136
|
+
end
|
|
124
137
|
end
|
|
125
138
|
end
|
|
126
139
|
end
|
data/lib/full_search/railtie.rb
CHANGED
|
@@ -10,16 +10,10 @@ module FullSearch
|
|
|
10
10
|
|
|
11
11
|
initializer "full_search.ensure_tables" do
|
|
12
12
|
config.after_initialize do
|
|
13
|
-
next unless FullSearch.config.
|
|
13
|
+
next unless FullSearch.config.auto_rebuild_schema
|
|
14
14
|
ActiveSupport.on_load(:active_record) do
|
|
15
15
|
FullSearch.models.each do |model|
|
|
16
|
-
FullSearch::Index.
|
|
17
|
-
next unless FullSearch.config.auto_manage_schema == true
|
|
18
|
-
|
|
19
|
-
stored = FullSearch::Index.stored_config_hash(model)
|
|
20
|
-
if stored && stored != model.full_search_dsl.config_hash
|
|
21
|
-
FullSearch::Index.rebuild!(model)
|
|
22
|
-
end
|
|
16
|
+
FullSearch::Index.rebuild_if_needed!(model)
|
|
23
17
|
end
|
|
24
18
|
end
|
|
25
19
|
end
|
data/lib/full_search/search.rb
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module FullSearch
|
|
4
4
|
class Search
|
|
5
|
-
attr_reader :model, :query, :filters, :include_soft_deleted, :limit, :offset, :highlight, :highlight_fields
|
|
5
|
+
attr_reader :model, :query, :filters, :include_soft_deleted, :limit, :offset, :highlight, :highlight_fields, :matching_strategy
|
|
6
6
|
|
|
7
|
-
def initialize(model, query, filters:, include_soft_deleted:, limit:, offset:, highlight: false, highlight_fields: false)
|
|
7
|
+
def initialize(model, query, filters:, include_soft_deleted:, limit:, offset:, highlight: false, highlight_fields: false, matching_strategy: nil)
|
|
8
8
|
@model = model
|
|
9
9
|
@query = query.to_s.strip
|
|
10
10
|
@filters = filters
|
|
@@ -13,17 +13,25 @@ module FullSearch
|
|
|
13
13
|
@offset = offset
|
|
14
14
|
@highlight = highlight
|
|
15
15
|
@highlight_fields = highlight_fields
|
|
16
|
+
@matching_strategy = matching_strategy
|
|
16
17
|
end
|
|
17
18
|
|
|
19
|
+
MIN_TERM_LENGTH = 3
|
|
20
|
+
|
|
18
21
|
def relation
|
|
22
|
+
validate_filter_keys!
|
|
19
23
|
validate_required_filters!
|
|
24
|
+
check_stale_config!
|
|
25
|
+
|
|
26
|
+
return model.none if dsl.tokenize == "trigram" && query.length < MIN_TERM_LENGTH && !dsl.typo_tolerance?
|
|
20
27
|
|
|
21
28
|
parsed = QueryParser.parse(query)
|
|
22
29
|
exact_ids = ExactMatch.ids_for(model, query, filters)
|
|
23
30
|
primary_ids = fts_match_ids(parsed)
|
|
24
|
-
fallback_ids = dsl.typo_tolerance? ? trigram_match_ids(parsed, primary_ids) : []
|
|
31
|
+
fallback_ids = (dsl.typo_tolerance? && matching_strategy != "all") ? trigram_match_ids(parsed, primary_ids) : []
|
|
32
|
+
fuzzy_ids = (dsl.typo_tolerance? && matching_strategy != "all" && primary_ids.empty? && fallback_ids.empty?) ? fuzzy_match_ids(parsed) : []
|
|
25
33
|
|
|
26
|
-
all_ids = (exact_ids + primary_ids + fallback_ids).uniq
|
|
34
|
+
all_ids = (exact_ids + primary_ids + fallback_ids + fuzzy_ids).uniq
|
|
27
35
|
return model.none if all_ids.empty?
|
|
28
36
|
|
|
29
37
|
rel = model.where(id: all_ids)
|
|
@@ -48,6 +56,15 @@ module FullSearch
|
|
|
48
56
|
model.full_search_dsl
|
|
49
57
|
end
|
|
50
58
|
|
|
59
|
+
def validate_filter_keys!
|
|
60
|
+
allowed = dsl.filters.map(&:name).to_set
|
|
61
|
+
filters.each_key do |key|
|
|
62
|
+
unless allowed.include?(key.to_s)
|
|
63
|
+
raise UnknownFilterError, "Unknown filter: #{key}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
51
68
|
def validate_required_filters!
|
|
52
69
|
dsl.filters.each do |filter|
|
|
53
70
|
next unless filter.required
|
|
@@ -55,21 +72,36 @@ module FullSearch
|
|
|
55
72
|
end
|
|
56
73
|
end
|
|
57
74
|
|
|
75
|
+
def check_stale_config!
|
|
76
|
+
stored = FullSearch::Index.stored_config_hash(model)
|
|
77
|
+
return unless stored
|
|
78
|
+
|
|
79
|
+
if stored != dsl.config_hash
|
|
80
|
+
case FullSearch.config.stale_query_behavior
|
|
81
|
+
when :raise
|
|
82
|
+
raise ConfigChangedError, "FTS index for #{model.table_name} is stale; run full_search:rebuild"
|
|
83
|
+
when :log_and_fallback
|
|
84
|
+
Rails.logger.warn("[full_search] FTS index for #{model.table_name} is stale; results may be incomplete")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
58
89
|
def fts_match_ids(parsed)
|
|
59
90
|
return [] if query.empty?
|
|
60
91
|
|
|
61
92
|
match_expr = QueryParser.to_match_expression(parsed)
|
|
62
|
-
fts_table = FullSearch::Index.fts_table_name(model)
|
|
93
|
+
fts_table = qt(FullSearch::Index.fts_table_name(model))
|
|
94
|
+
tbl = qt(model.table_name)
|
|
63
95
|
|
|
64
96
|
sql = <<~SQL
|
|
65
|
-
SELECT #{
|
|
97
|
+
SELECT #{tbl}.id
|
|
66
98
|
FROM #{fts_table}
|
|
67
|
-
JOIN #{
|
|
68
|
-
WHERE #{fts_table} MATCH #{
|
|
99
|
+
JOIN #{tbl} ON #{tbl}.id = #{fts_table}.rowid
|
|
100
|
+
WHERE #{fts_table} MATCH #{q(match_expr)}
|
|
69
101
|
SQL
|
|
70
102
|
|
|
71
103
|
filter_conditions = filters.map do |name, value|
|
|
72
|
-
"AND #{fts_table}.#{name} = #{
|
|
104
|
+
"AND #{fts_table}.#{qc(name)} = #{q(value)}"
|
|
73
105
|
end.join(" ")
|
|
74
106
|
|
|
75
107
|
connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
|
|
@@ -81,76 +113,171 @@ module FullSearch
|
|
|
81
113
|
match_expr = QueryParser.to_match_expression(parsed)
|
|
82
114
|
return [] if match_expr.empty?
|
|
83
115
|
|
|
84
|
-
term = parsed.last
|
|
116
|
+
term = parsed.last
|
|
85
117
|
return [] if term.nil?
|
|
86
118
|
|
|
87
119
|
if term.length < dsl.typo_tolerance_min_term_length.to_i
|
|
88
120
|
return like_prefix_ids(term)
|
|
89
121
|
end
|
|
90
122
|
|
|
91
|
-
trigram_table = FullSearch::Index.trigram_table_name(model)
|
|
123
|
+
trigram_table = qt(FullSearch::Index.trigram_table_name(model))
|
|
124
|
+
tbl = qt(model.table_name)
|
|
92
125
|
|
|
93
126
|
sql = <<~SQL
|
|
94
|
-
SELECT #{
|
|
127
|
+
SELECT #{tbl}.id
|
|
95
128
|
FROM #{trigram_table}
|
|
96
|
-
JOIN #{
|
|
97
|
-
WHERE #{trigram_table} MATCH #{
|
|
129
|
+
JOIN #{tbl} ON #{tbl}.id = #{trigram_table}.rowid
|
|
130
|
+
WHERE #{trigram_table} MATCH #{q(match_expr)}
|
|
98
131
|
SQL
|
|
99
132
|
|
|
100
133
|
filter_conditions = filters.map do |name, value|
|
|
101
|
-
"AND #{trigram_table}.#{name} = #{
|
|
134
|
+
"AND #{trigram_table}.#{qc(name)} = #{q(value)}"
|
|
102
135
|
end.join(" ")
|
|
103
136
|
|
|
104
137
|
connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
|
|
105
138
|
end
|
|
106
139
|
|
|
107
140
|
def like_prefix_ids(term)
|
|
141
|
+
column_fields = dsl.fields.select { |f| f.source.nil? }
|
|
142
|
+
source_fields = dsl.fields.select { |f| f.source }
|
|
143
|
+
tbl = qt(model.table_name)
|
|
144
|
+
|
|
145
|
+
soft_delete_clause = ""
|
|
146
|
+
if dsl.soft_delete_column && !include_soft_deleted
|
|
147
|
+
soft_delete_clause = "AND #{tbl}.#{qc(dsl.soft_delete_column)} IS NULL"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
ids = []
|
|
151
|
+
|
|
152
|
+
if column_fields.any?
|
|
153
|
+
like_conditions = column_fields.map do |field|
|
|
154
|
+
"#{tbl}.#{qc(field.name)} LIKE #{q("#{term}%")}"
|
|
155
|
+
end.join(" OR ")
|
|
156
|
+
|
|
157
|
+
filter_conditions = filters.map do |name, value|
|
|
158
|
+
"AND #{tbl}.#{qc(name)} = #{q(value)}"
|
|
159
|
+
end.join(" ")
|
|
160
|
+
|
|
161
|
+
sql = <<~SQL
|
|
162
|
+
SELECT #{tbl}.id
|
|
163
|
+
FROM #{tbl}
|
|
164
|
+
WHERE (#{like_conditions}) #{filter_conditions} #{soft_delete_clause}
|
|
165
|
+
SQL
|
|
166
|
+
|
|
167
|
+
ids = connection.execute(sql).map { |r| r["id"] }
|
|
168
|
+
return ids if ids.any?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if source_fields.any?
|
|
172
|
+
fts_table = qt(FullSearch::Index.fts_table_name(model))
|
|
173
|
+
like_conditions = source_fields.map do |field|
|
|
174
|
+
"#{fts_table}.#{qc(field.name)} LIKE #{q("#{term}%")}"
|
|
175
|
+
end.join(" OR ")
|
|
176
|
+
|
|
177
|
+
filter_conditions = filters.map do |name, value|
|
|
178
|
+
"AND #{fts_table}.#{qc(name)} = #{q(value)}"
|
|
179
|
+
end.join(" ")
|
|
180
|
+
|
|
181
|
+
sql = <<~SQL
|
|
182
|
+
SELECT #{tbl}.id
|
|
183
|
+
FROM #{fts_table}
|
|
184
|
+
JOIN #{tbl} ON #{tbl}.id = #{fts_table}.rowid
|
|
185
|
+
WHERE (#{like_conditions}) #{filter_conditions} #{soft_delete_clause}
|
|
186
|
+
SQL
|
|
187
|
+
|
|
188
|
+
ids = connection.execute(sql).map { |r| r["id"] }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
ids
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def fuzzy_match_ids(parsed)
|
|
195
|
+
term = parsed.last
|
|
196
|
+
return [] if term.nil?
|
|
197
|
+
|
|
198
|
+
term_str = term.is_a?(Array) ? extract_last_term_string(term) : term.to_s
|
|
199
|
+
return [] if term_str.empty?
|
|
200
|
+
|
|
201
|
+
max_typos = max_allowed_typos(term_str.length)
|
|
202
|
+
return [] if max_typos < 0
|
|
203
|
+
|
|
108
204
|
column_fields = dsl.fields.select { |f| f.source.nil? }
|
|
109
205
|
return [] if column_fields.empty?
|
|
110
206
|
|
|
111
|
-
|
|
112
|
-
|
|
207
|
+
register_levenshtein!
|
|
208
|
+
tbl = qt(model.table_name)
|
|
209
|
+
|
|
210
|
+
soft_delete_clause = ""
|
|
211
|
+
if dsl.soft_delete_column && !include_soft_deleted
|
|
212
|
+
soft_delete_clause = "AND #{tbl}.#{qc(dsl.soft_delete_column)} IS NULL"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
filter_conditions = filters.map do |name, value|
|
|
216
|
+
"AND #{tbl}.#{qc(name)} = #{q(value)}"
|
|
217
|
+
end.join(" ")
|
|
218
|
+
|
|
219
|
+
conditions = column_fields.map do |field|
|
|
220
|
+
"levenshtein(LOWER(#{tbl}.#{qc(field.name)}), #{q(term_str.downcase)}) <= #{max_typos}"
|
|
113
221
|
end.join(" OR ")
|
|
114
222
|
|
|
115
223
|
sql = <<~SQL
|
|
116
|
-
SELECT #{
|
|
117
|
-
FROM #{
|
|
118
|
-
WHERE (#{
|
|
224
|
+
SELECT #{tbl}.id
|
|
225
|
+
FROM #{tbl}
|
|
226
|
+
WHERE (#{conditions}) #{filter_conditions} #{soft_delete_clause}
|
|
119
227
|
SQL
|
|
120
228
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
end.join(" ")
|
|
229
|
+
connection.execute(sql).map { |r| r["id"] }
|
|
230
|
+
end
|
|
124
231
|
|
|
125
|
-
|
|
232
|
+
def max_allowed_typos(length)
|
|
233
|
+
min_length = dsl.typo_tolerance_min_term_length.to_i
|
|
234
|
+
return -1 if length < min_length
|
|
235
|
+
return 2 if length >= 9
|
|
236
|
+
1
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def extract_last_term_string(terms)
|
|
240
|
+
last = terms.last
|
|
241
|
+
last.is_a?(Array) ? last.last.to_s : last.to_s
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def register_levenshtein!
|
|
245
|
+
return if @levenshtein_registered
|
|
246
|
+
|
|
247
|
+
raw = connection.raw_connection
|
|
248
|
+
raw.create_function("levenshtein", 2) do |func, s1, s2|
|
|
249
|
+
func.result = Distance.damerau_levenshtein(s1.to_s, s2.to_s)
|
|
250
|
+
end
|
|
251
|
+
@levenshtein_registered = true
|
|
126
252
|
end
|
|
127
253
|
|
|
128
254
|
def apply_ranking(rel, all_ids, exact_ids)
|
|
129
255
|
return rel if all_ids.empty?
|
|
130
256
|
|
|
131
257
|
order_parts = []
|
|
258
|
+
tbl = qt(model.table_name)
|
|
132
259
|
|
|
133
260
|
if exact_ids.any?
|
|
134
|
-
order_parts << "CASE #{
|
|
261
|
+
order_parts << "CASE #{tbl}.id #{exact_ids.map { |id| "WHEN #{q(id)} THEN 0" }.join(" ")} ELSE 1 END"
|
|
135
262
|
end
|
|
136
263
|
|
|
137
|
-
fts_table = FullSearch::Index.fts_table_name(model)
|
|
264
|
+
fts_table = qt(FullSearch::Index.fts_table_name(model))
|
|
138
265
|
match_expr = QueryParser.to_match_expression(QueryParser.parse(query))
|
|
139
266
|
|
|
140
267
|
rank_subquery = <<~SQL
|
|
141
268
|
SELECT rowid, rank
|
|
142
269
|
FROM #{fts_table}
|
|
143
|
-
WHERE #{fts_table} MATCH #{
|
|
270
|
+
WHERE #{fts_table} MATCH #{q(match_expr)}
|
|
144
271
|
SQL
|
|
145
272
|
|
|
146
273
|
rel = rel
|
|
147
|
-
.select("#{
|
|
148
|
-
.joins("LEFT JOIN (#{rank_subquery}) AS fts_rank ON fts_rank.rowid = #{
|
|
274
|
+
.select("#{tbl}.*, fts_rank.rank AS full_search_rank")
|
|
275
|
+
.joins("LEFT JOIN (#{rank_subquery}) AS fts_rank ON fts_rank.rowid = #{tbl}.id")
|
|
149
276
|
|
|
150
277
|
order_parts << "COALESCE(fts_rank.rank, 1)"
|
|
151
278
|
|
|
152
279
|
dsl.rank_bys.each do |rank_by|
|
|
153
|
-
col = "#{
|
|
280
|
+
col = "#{tbl}.#{qc(rank_by.column)}"
|
|
154
281
|
order_parts << "#{col} #{rank_by.direction.to_s.upcase} NULLS LAST"
|
|
155
282
|
end
|
|
156
283
|
|
|
@@ -158,7 +285,11 @@ module FullSearch
|
|
|
158
285
|
end
|
|
159
286
|
|
|
160
287
|
def connection
|
|
288
|
+
model.connection
|
|
289
|
+
rescue NoMethodError
|
|
161
290
|
ActiveRecord::Base.connection
|
|
162
291
|
end
|
|
292
|
+
|
|
293
|
+
include Quoting
|
|
163
294
|
end
|
|
164
295
|
end
|
|
@@ -2,11 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module FullSearch
|
|
4
4
|
module SoftDelete
|
|
5
|
-
def self.
|
|
5
|
+
def self.active_update_clause(model)
|
|
6
6
|
dsl = model.full_search_dsl
|
|
7
7
|
return nil unless dsl&.soft_delete_column
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
col = model.connection.quote_column_name(dsl.soft_delete_column)
|
|
10
|
+
"WHEN new.#{col} IS NULL"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.soft_delete_remove_clause(model)
|
|
14
|
+
dsl = model.full_search_dsl
|
|
15
|
+
return nil unless dsl&.soft_delete_column
|
|
16
|
+
|
|
17
|
+
col = model.connection.quote_column_name(dsl.soft_delete_column)
|
|
18
|
+
"WHEN old.#{col} IS NULL AND new.#{col} IS NOT NULL"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.delete_transition_sql(model)
|
|
22
|
+
active_update_clause(model)
|
|
10
23
|
end
|
|
11
24
|
end
|
|
12
25
|
end
|