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.
@@ -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
- FullSearch.models << self unless FullSearch.models.include?(self)
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
- { groups: searched, total_count: searched.sum { |g| g[:total_count] } }
46
+ {groups: searched, total_count: searched.sum { |g| g[:total_count] }}
44
47
  end
45
48
 
46
49
  private
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module FullSearch
6
+ class OptimizeJob < ActiveJob::Base
7
+ queue_as :low
8
+
9
+ def perform
10
+ FullSearch.optimize!
11
+ end
12
+ end
13
+ end
@@ -6,121 +6,134 @@ module FullSearch
6
6
  module QueryParser
7
7
  Token = Data.define(:type, :value)
8
8
 
9
- def self.parse(query)
10
- tokens = tokenize(query.to_s.strip)
11
- return [] if tokens.empty?
9
+ MAX_QUERY_LENGTH = 255
12
10
 
13
- or_clauses = split_or(tokens)
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
- if or_clauses.size == 1
16
- parse_and_clause(or_clauses.first)
17
- else
18
- [:or, or_clauses.map { |clause| parse_and_clause(clause) }]
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
- def self.to_match_expression(parsed)
23
- return '""' if parsed.empty?
24
-
25
- type, value = parsed
26
- case type
27
- when :and
28
- build_and_expression(value)
29
- when :or
30
- value.map { |sub| to_match_expression(sub) }.join(" OR ")
31
- else
32
- node_to_match(parsed)
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
- private
41
+ private
37
42
 
38
- def self.build_and_expression(nodes)
39
- non_excludes = nodes.reject { |n| n.first == :exclude }
40
- excludes = nodes.select { |n| n.first == :exclude }
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
- parts = non_excludes.map { |n| node_to_match(n) }
43
- result = parts.size == 1 ? parts.first : "(#{parts.join(' AND ')})"
47
+ parts = non_excludes.map { |n| node_to_match(n) }
48
+ result = (parts.size == 1) ? parts.first : "(#{parts.join(" AND ")})"
44
49
 
45
- excludes.each do |node|
46
- _, value = node
47
- result = "#{result} NOT \"#{escape(value)}\""
48
- end
50
+ excludes.each do |node|
51
+ _, value = node
52
+ result = "#{result} NOT \"#{escape(value)}\""
53
+ end
49
54
 
50
- result
51
- end
55
+ result
56
+ end
52
57
 
53
- def self.node_to_match(node)
54
- type, value = node
55
- case type
56
- when :term
57
- '"' + escape(value) + '"*'
58
- when :phrase
59
- '"' + escape(value) + '"'
60
- when :exclude
61
- nil
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
- def self.tokenize(query)
66
- tokens = []
67
- scanner = StringScanner.new(query)
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
- until scanner.eos?
70
- scanner.skip(/\s+/)
71
- break if scanner.eos?
77
+ def tokenize(query)
78
+ tokens = []
79
+ scanner = StringScanner.new(query)
72
80
 
73
- if scanner.scan(/"/)
74
- phrase = scanner.scan_until(/"/)
75
- if phrase
76
- tokens << Token.new(:phrase, phrase.chomp('"'))
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.rest)
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
- tokens
92
- end
103
+ tokens
104
+ end
93
105
 
94
- def self.split_or(tokens)
95
- clauses = []
96
- current = []
106
+ def split_or(tokens)
107
+ clauses = []
108
+ current = []
97
109
 
98
- tokens.each do |token|
99
- if token.type == :or
100
- clauses << current unless current.empty?
101
- current = []
102
- else
103
- current << token
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
- def self.parse_and_clause(tokens)
111
- nodes = tokens.map do |token|
112
- case token.type
113
- when :term then [:term, token.value]
114
- when :phrase then [:phrase, token.value]
115
- when :exclude then [:exclude, token.value]
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
- nodes.size == 1 ? nodes.first : [:and, nodes]
120
- end
131
+ (nodes.size == 1) ? nodes.first : [:and, nodes]
132
+ end
121
133
 
122
- def self.escape(value)
123
- value.to_s.gsub('"', '""')
134
+ def escape(value)
135
+ value.to_s.gsub('"', '""')
136
+ end
124
137
  end
125
138
  end
126
139
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ module Quoting
5
+ private
6
+
7
+ def q(value) = connection.quote(value)
8
+ def qt(name) = connection.quote_table_name(name)
9
+ def qc(name) = connection.quote_column_name(name)
10
+ end
11
+ end
@@ -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.auto_manage_schema
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.ensure_table!(model)
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
@@ -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 #{model.table_name}.id
97
+ SELECT #{tbl}.id
66
98
  FROM #{fts_table}
67
- JOIN #{model.table_name} ON #{model.table_name}.id = #{fts_table}.rowid
68
- WHERE #{fts_table} MATCH #{connection.quote(match_expr)}
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} = #{connection.quote(value)}"
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 rescue nil
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 #{model.table_name}.id
127
+ SELECT #{tbl}.id
95
128
  FROM #{trigram_table}
96
- JOIN #{model.table_name} ON #{model.table_name}.id = #{trigram_table}.rowid
97
- WHERE #{trigram_table} MATCH #{connection.quote(match_expr)}
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} = #{connection.quote(value)}"
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
- like_conditions = column_fields.map do |field|
112
- "#{connection.quote_table_name(model.table_name)}.#{connection.quote_column_name(field.name)} LIKE #{connection.quote("#{term}%")}"
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 #{model.table_name}.id
117
- FROM #{model.table_name}
118
- WHERE (#{like_conditions})
224
+ SELECT #{tbl}.id
225
+ FROM #{tbl}
226
+ WHERE (#{conditions}) #{filter_conditions} #{soft_delete_clause}
119
227
  SQL
120
228
 
121
- filter_conditions = filters.map do |name, value|
122
- "AND #{model.table_name}.#{name} = #{connection.quote(value)}"
123
- end.join(" ")
229
+ connection.execute(sql).map { |r| r["id"] }
230
+ end
124
231
 
125
- connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
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 #{model.table_name}.id #{exact_ids.map { |id| "WHEN #{id} THEN 0" }.join(" ")} ELSE 1 END"
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 #{connection.quote(match_expr)}
270
+ WHERE #{fts_table} MATCH #{q(match_expr)}
144
271
  SQL
145
272
 
146
273
  rel = rel
147
- .select("#{model.table_name}.*, fts_rank.rank AS full_search_rank")
148
- .joins("LEFT JOIN (#{rank_subquery}) AS fts_rank ON fts_rank.rowid = #{model.table_name}.id")
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 = "#{connection.quote_table_name(model.table_name)}.#{connection.quote_column_name(rank_by.column)}"
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.delete_transition_sql(model)
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
- "WHEN new.#{dsl.soft_delete_column} IS NULL"
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