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
|
# 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])
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, 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
# 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
|