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
|