Module: SearchHelper

Defined in:
app/helpers/search_helper.rb

Instance Method Summary collapse

Instance Method Details

#active_filterHash{Symbol => #to_s}

Provides a filter-like object containing keys for each of the filter parameters.

Returns:

  • (Hash{Symbol => #to_s})


66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'app/helpers/search_helper.rb', line 66

def active_filter
  {
    default: nil,
    name: params[:predefined_filter],
    min_score: params[:min_score],
    max_score: params[:max_score],
    min_answers: params[:min_answers],
    max_answers: params[:max_answers],
    include_tags: params[:include_tags],
    exclude_tags: params[:exclude_tags],
    status: params[:status]
  }
end

#check_posts_permissionsObject



2
3
4
5
# File 'app/helpers/search_helper.rb', line 2

def check_posts_permissions
  (current_user&.is_moderator || current_user&.is_admin ? Post : Post.undeleted)
    .qa_only.list_includes
end

#date_value_sql(value) ⇒ Array(String, String, String)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Parses a qualifier value string, including operator, as a date value.

Parameters:

  • value (String)

    The value part of the qualifier, i.e. ā€œ>=10dā€

Returns:

  • (Array(String, String, String))

    A 3-tuple containing operator, value, and timeframe.



300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
# File 'app/helpers/search_helper.rb', line 300

def date_value_sql(value)
  operator = ''

  while ['<', '>', '='].include? value[0]
    operator += value[0]
    value = value[1..-1]
  end

  # working with dates: <1y ('less than one year ago') is SQL: > 1y ago
  operator = { '<' => '>', '>' => '<', '<=' => '>=', '>=' => '<=' }[operator] || ''

  val = ''
  while value[0] =~ /[[:digit:]]/
    val += value[0]
    value = value[1..-1]
  end

  timeframe = { s: 'SECOND', m: 'MINUTE', h: 'HOUR', d: 'DAY', w: 'WEEK', mo: 'MONTH', y: 'YEAR' }[value.to_sym]

  [operator, val, timeframe || 'MONTH']
end

#filter_to_qualifiers(filter) ⇒ Array<Hash{Symbol => Object}>

Converts a Filter record into a form parseable by the search function.

Parameters:

Returns:

  • (Array<Hash{Symbol => Object}>)

    An array of hashes, each containing at least a param key and other relevant information.



51
52
53
54
55
56
57
58
59
60
61
# File 'app/helpers/search_helper.rb', line 51

def filter_to_qualifiers(filter)
  qualifiers = []
  qualifiers.append({ param: :score, operator: '>=', value: filter.min_score }) unless filter.min_score.nil?
  qualifiers.append({ param: :score, operator: '<=', value: filter.max_score }) unless filter.max_score.nil?
  qualifiers.append({ param: :answers, operator: '>=', value: filter.min_answers }) unless filter.min_answers.nil?
  qualifiers.append({ param: :answers, operator: '<=', value: filter.max_answers }) unless filter.max_answers.nil?
  qualifiers.append({ param: :include_tags, tag_ids: filter.include_tags }) unless filter.include_tags.nil?
  qualifiers.append({ param: :exclude_tags, tag_ids: filter.exclude_tags }) unless filter.exclude_tags.nil?
  qualifiers.append({ param: :status, value: filter.status }) unless filter.status.nil?
  qualifiers
end

#numeric_value_sql(value) ⇒ Array(String, String)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Parses a qualifier value string, including operator, as a numeric value.

Parameters:

  • value (String)

    The value part of the qualifier, i.e. ā€œ>=10ā€

Returns:

  • (Array(String, String))

    A 2-tuple containing operator and value.



283
284
285
286
287
288
289
290
291
292
293
# File 'app/helpers/search_helper.rb', line 283

def numeric_value_sql(value)
  operator = ''
  while ['<', '>', '='].include? value[0]
    operator += value[0]
    value = value[1..-1]
  end

  # whatever's left after stripping operator is the number
  # validated by regex in qualifiers_to_sql
  [operator, value]
end

#params_to_qualifiersArray<Hash{Symbol => Object}>

Retrieves parameters from params, validates their values, and adds them to a qualifiers hash.

Returns:

  • (Array<Hash{Symbol => Object}>)


83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'app/helpers/search_helper.rb', line 83

def params_to_qualifiers
  valid_value = {
    date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/,
    status: /any|open|closed/,
    numeric: /^[\d.]+$/,
    integer: /^\d+$/
  }

  filter_qualifiers = []

  if params[:min_score]&.match?(valid_value[:numeric])
    filter_qualifiers.append({ param: :score, operator: '>=', value: params[:min_score].to_f })
  end

  if params[:max_score]&.match?(valid_value[:numeric])
    filter_qualifiers.append({ param: :score, operator: '<=', value: params[:max_score].to_f })
  end

  if params[:min_answers]&.match?(valid_value[:numeric])
    filter_qualifiers.append({ param: :answers, operator: '>=', value: params[:min_answers].to_i })
  end

  if params[:max_answers]&.match?(valid_value[:numeric])
    filter_qualifiers.append({ param: :answers, operator: '<=', value: params[:max_answers].to_i })
  end

  if params[:status]&.match?(valid_value[:status])
    filter_qualifiers.append({ param: :status, value: params[:status] })
  end

  if params[:include_tags]&.all? { |id| id.match? valid_value[:integer] }
    filter_qualifiers.append({ param: :include_tags, tag_ids: params[:include_tags] })
  end

  if params[:exclude_tags]&.all? { |id| id.match? valid_value[:integer] }
    filter_qualifiers.append({ param: :exclude_tags, tag_ids: params[:exclude_tags] })
  end

  filter_qualifiers
end

#parse_qualifier_strings(qualifiers) ⇒ Array<Hash{Symbol => Object}>

Parses a full qualifier string into an array of qualifier objects.

Parameters:

  • qualifiers (String)

    A qualifier string as returned by #parse_search.

Returns:

  • (Array<Hash{Symbol => Object}>)


144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'app/helpers/search_helper.rb', line 144

def parse_qualifier_strings(qualifiers)
  valid_value = {
    date: /^[<>=]{0,2}[\d.]+(?:s|m|h|d|w|mo|y)?$/,
    status: /any|open|closed/,
    numeric: /^[<>=]{0,2}[\d.]+$/
  }

  qualifiers.map do |qualifier| # rubocop:disable Metrics/BlockLength
    splat = qualifier.split ':'
    parameter = splat[0]
    value = splat[1]

    case parameter
    when 'score'
      next unless value.match?(valid_value[:numeric])

      operator, val = numeric_value_sql value
      { param: :score, operator: operator.presence || '=', value: val.to_f }
    when 'created'
      next unless value.match?(valid_value[:date])

      operator, val, timeframe = date_value_sql value
      { param: :created, operator: operator.presence || '=', timeframe: timeframe, value: val.to_i }
    when 'user'
      operator, val = if value.match?(valid_value[:numeric])
                        numeric_value_sql value
                      elsif value == 'me'
                        ['=', current_user&.id&.to_i]
                      else
                        next
                      end

      { param: :user, operator: operator.presence || '=', user_id: val }
    when 'upvotes'
      next unless value.match?(valid_value[:numeric])

      operator, val = numeric_value_sql value
      { param: :upvotes, operator: operator.presence || '=', value: val.to_i }
    when 'downvotes'
      next unless value.match?(valid_value[:numeric])

      operator, val = numeric_value_sql value
      { param: :downvotes, operator: operator.presence || '=', value: val.to_i }
    when 'votes'
      next unless value.match?(valid_value[:numeric])

      operator, val = numeric_value_sql value
      { param: :net_votes, operator: operator.presence || '=', value: val.to_i }
    when 'tag'
      { param: :include_tag, tag_id: Tag.where(name: value).select(:id) }
    when '-tag'
      { param: :exclude_tag, tag_id: Tag.where(name: value).select(:id) }
    when 'category'
      next unless value.match?(valid_value[:numeric])

      operator, val = numeric_value_sql value
      { param: :category, operator: operator.presence || '=', category_id: val.to_i }
    when 'post_type'
      next unless value.match?(valid_value[:numeric])

      operator, val = numeric_value_sql value
      { param: :post_type, operator: operator.presence || '=', post_type_id: val.to_i }
    when 'answers'
      next unless value.match?(valid_value[:numeric])

      operator, val = numeric_value_sql value
      { param: :answers, operator: operator.presence || '=', value: val.to_i }
    when 'status'
      next unless value.match?(valid_value[:status])

      { param: :status, value: value }
    end
  end.compact
  # Consider partitioning and telling the user which filters were invalid
end

#parse_search(raw_search) ⇒ Hash{Symbol => String}

Parses a raw search string and returns the base search term and qualifier strings separately.

Returns:

  • (Hash{Symbol => String})

    A hash containing :qualifiers and :search keys.



127
128
129
130
131
132
133
134
135
136
# File 'app/helpers/search_helper.rb', line 127

def parse_search(raw_search)
  qualifiers_regex = /([\w\-_]+(?<!\\):[^ ]+)/
  qualifiers = raw_search.scan(qualifiers_regex).flatten
  search = raw_search
  qualifiers.each do |q|
    search = search.gsub(q, '')
  end
  search = search.gsub(/\\:/, ':').strip
  { qualifiers: qualifiers, search: search }
end

#qualifiers_to_sql(qualifiers, query) ⇒ ActiveRecord::Relation

Parses a qualifiers hash and applies it to an ActiveRecord query.

Parameters:

  • qualifiers (Array<Hash{Symbol => Object}>)

    A qualifiers hash, as returned by other methods in this module.

  • query (ActiveRecord::Relation)

    An ActiveRecord query to which to add conditions based on the qualifiers.

Returns:

  • (ActiveRecord::Relation)


225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'app/helpers/search_helper.rb', line 225

def qualifiers_to_sql(qualifiers, query)
  trust_level = current_user&.trust_level || 0
  allowed_categories = Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level)
  query = query.where(category_id: allowed_categories)

  qualifiers.each do |qualifier| # rubocop:disable Metrics/BlockLength
    case qualifier[:param]
    when :score
      query = query.where("score #{qualifier[:operator]} ?", qualifier[:value])
    when :created
      query = query.where("created_at #{qualifier[:operator]} DATE_SUB(CURRENT_TIMESTAMP, " \
                          "INTERVAL ? #{qualifier[:timeframe]})",
                          qualifier[:value])
    when :user
      query = query.where("user_id #{qualifier[:operator]} ?", qualifier[:user_id])
    when :upvotes
      query = query.where("upvote_count #{qualifier[:operator]} ?", qualifier[:value])
    when :downvotes
      query = query.where("downvote_count #{qualifier[:operator]} ?", qualifier[:value])
    when :net_votes
      query = query.where("(upvote_count - downvote_count) #{qualifier[:operator]} ?", qualifier[:value])
    when :include_tag
      query = query.where(posts: { id: PostsTag.where(tag_id: qualifier[:tag_id]).select(:post_id) })
    when :include_tags
      qualifier[:tag_ids].each do |id|
        query = query.where(id: PostsTag.where(tag_id: id).select(:post_id))
      end
    when :exclude_tag
      query = query.where.not(posts: { id: PostsTag.where(tag_id: qualifier[:tag_id]).select(:post_id) })
    when :exclude_tags
      query = query.where.not(id: PostsTag.where(tag_id: qualifier[:tag_ids]).select(:post_id))
    when :category
      query = query.where("category_id #{qualifier[:operator]} ?", qualifier[:category_id])
    when :post_type
      query = query.where("post_type_id #{qualifier[:operator]} ?", qualifier[:post_type_id])
    when :answers
      post_types_with_answers = PostType.where(has_answers: true)
      query = query.where("answer_count #{qualifier[:operator]} ?", qualifier[:value])
                   .where(post_type_id: post_types_with_answers)
    when :status
      case qualifier[:value]
      when 'open'
        query = query.where(closed: false)
      when 'closed'
        query = query.where(closed: true)
      end
    end
  end

  query
end

#search_postsActiveRecord::Relation<Post>

Search & sort a default posts list based on parameters in the current request.

Generates initial post list using Post#qa_only, including deleted posts for mods and admins. Takes search string from params[:search], applies any qualifiers, and searches post bodies for the remaining term(s).

Search uses MySQL fulltext search in boolean mode which is what provides advanced search syntax (excluding qualifiers) - see MySQL manual 14.9.2.

Returns:

  • (ActiveRecord::Relation<Post>)


17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'app/helpers/search_helper.rb', line 17

def search_posts
  posts = check_posts_permissions

  qualifiers = params_to_qualifiers
  search_string = params[:search]

  # Filter based on search string qualifiers
  if search_string.present?
    search_data = parse_search(search_string)
    qualifiers += parse_qualifier_strings search_data[:qualifiers]
    search_string = search_data[:search]
  end

  posts = qualifiers_to_sql(qualifiers, posts)
  posts = posts.paginate(page: params[:page], per_page: 25)

  posts = if search_string.present?
            posts.search(search_data[:search]).user_sort({ term: params[:sort], default: :search_score },
                                                         relevance: :search_score,
                                                         score: :score, age: :created_at,
                                                         activity: :updated_at)
          else
            posts.user_sort({ term: params[:sort], default: :score },
                            score: :score, age: :created_at, activity: :updated_at)
          end

  [posts, qualifiers]
end