aboutsummaryrefslogtreecommitdiff
path: root/app/models/search.rb
blob: 9886f366d6dbe9a954d6987fff64d10ce9556fa7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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
123
124
125
126
127
128
# frozen_string_literal: true

class Search < ApplicationRecord
  validates :name, presence: true, uniqueness: true
  validates :body, presence: true, uniqueness: true

  belongs_to :user

  has_rich_text :description

  # Returns all the results that can be fetched with the current `body`. It's
  # returned into a hash which groups the taggable types.
  def results
    # This can happen as part of an empty Search.new instance. That is, it's
    # still invalid, but it's the object being initialized for the default
    # search (i.e. just show me everything).
    return { things: Thing.order('rate DESC, title, created_at'), comments: [] } if body.blank?

    # Parse the body of the search, and return early if nothing was able to be
    # parsed (e.g. user wrote unknown identifiers).
    parsed = parse_body
    return { things: [], comments: [] } if parsed.all? { |_, v| v.blank? }

    # And add everything into the `res` hash with the results.
    res = find_by_text(plain: parsed[:plain])
    find_by_tags(res:, tags: parsed[:tag])
  end

  protected

  # Returns a hash which contains the tags that has been specified (`tag`) and
  # the plain text (`plain`) to check on the different fields.
  def parse_body
    res = { tag: [], plain: [] }

    split_body.each do |part|
      if part.include?(':')
        parts = part.split(':', 2)
        parts[1] = clean_clause(part: parts[1])

        next if parts[0] != 'tag'

        res[parts[0].to_sym] << parts[1]
      else
        res[:plain] << part
      end
    end

    res
  end

  # Returns the body split into its constituents. Note that we cannot mindlessly
  # do something like `body.split` because that would ignore quotes that are not
  # to be split among other things.
  def split_body
    cur = ''
    res = []
    co  = ''

    body.each_char do |c|
      if c.match?(/\s/) && co == ''
        res << cur if cur.present?
        cur = ''
      else
        # If it is a quote character, check whether it's being opened or
        # closed.
        co = c == co ? '' : c if c == '"' || c == "'"

        cur += c
      end
    end

    res << cur if cur.present? && co.blank?
    res
  end

  # Returns the argument without any leading/trailing quotes.
  def clean_clause(part:)
    part.gsub(/^("|')+/, '').gsub(/("|')+$/, '')
  end

  # Returns a hash which groups into taggable types the results by looking the
  # `plain` text into different fields.
  def find_by_text(plain:)
    res = { things: [], comments: [] }

    plain.each do |text|
      res[:things] = if res[:things].any?
                       res[:things].and(Thing.like(text:)).order('rate DESC, title, created_at')
                     else
                       Thing.like(text:).order('rate DESC, title, created_at')
                     end
      res[:comments] = if res[:comments].any?
                         res[:comments].and(Comment.like(text:)).order(:created_at)
                       else
                         Comment.like(text:).order(:created_at)
                       end
    end

    res
  end

  # Returns a hash which groups into taggable types the results by matching the
  # given `tags` which their references. It expects `res` to be already
  # initialized by `find_by_text` (yeah, great design, I know), which is also
  # the structure that will be returned.
  def find_by_tags(res:, tags:)
    return res if tags.blank?

    tags = tags.map { |name| Tag.find_by(name:) }

    query = TagReference.where(tag: tags, taggable_type: 'Thing')
    query = query.where(taggable_id: res[:things].pluck(:id)) if res[:things].present?
    res[:things] = query.joins('INNER JOIN things ON things.id = tag_references.taggable_id')
                        .group(:taggable_id)
                        .having('count(taggable_id) = ?', tags.size)
                        .order('things.rate DESC, things.title, things.created_at')
                        .map(&:taggable)

    query = TagReference.where(tag: tags, taggable_type: 'Comment')
    query = query.where(taggable_id: res[:comments].pluck(:id)) if res[:comments].present?
    res[:comments] = query.group(:taggable_id)
                          .having('count(taggable_id) = ?', tags.size)
                          .map(&:taggable)

    res
  end
end