aboutsummaryrefslogtreecommitdiff
path: root/app/models/search.rb
blob: 8a780bb9bd650c783a0bcf7746d7bb1222244543 (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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# 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).
    if body.blank?
      return { things:   Thing.order(rate: :desc, title: :asc, created_at: :asc),
               comments: [] }
    end

    # 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])
    res = filter_by_kind(res:, kind: parsed[:kind])
    find_by_tags(res:, tags: parsed[:tag], discard_comments: parsed[:kind].present?)
  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: [], kind: nil }

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

        case parts[0]
        when 'tag'
          res[parts[0].to_sym] << parts[1]
        when 'kind'
          res[:kind] = Thing.kinds[parts[1]]
        end
      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: :asc, created_at: :asc)
                     else
                       Thing.like(text:).order(rate: :desc, title: :asc, created_at: :asc)
                     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

  # Filter the given `res[:things]` part with the given `kind`. If `kind` is
  # nil, then the same `res` object is returned.
  def filter_by_kind(res:, kind:)
    return res unless kind

    res[:things] = if res[:things].any?
                     res[:things].and(Thing.where(kind:))
                   else
                     Thing.where(kind:)
                   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:, discard_comments:)
    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)

    if discard_comments
      res[:comments] = []
    else
      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)
    end

    res
  end
end