trample_search 0.14.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +46 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/rspec +16 -0
- data/bin/setup +7 -0
- data/lib/trample.rb +22 -0
- data/lib/trample/aggregation.rb +85 -0
- data/lib/trample/autocomplete/formatter.rb +53 -0
- data/lib/trample/backend/searchkick.rb +94 -0
- data/lib/trample/condition.rb +203 -0
- data/lib/trample/condition_proxy.rb +91 -0
- data/lib/trample/errors.rb +27 -0
- data/lib/trample/metadata.rb +41 -0
- data/lib/trample/railtie.rb +9 -0
- data/lib/trample/results.rb +9 -0
- data/lib/trample/search.rb +176 -0
- data/lib/trample/serializable.rb +7 -0
- data/lib/trample/swagger.rb +133 -0
- data/lib/trample/version.rb +3 -0
- data/trample.gemspec +32 -0
- metadata +210 -0
@@ -0,0 +1,91 @@
|
|
1
|
+
module Trample
|
2
|
+
class ConditionProxy
|
3
|
+
|
4
|
+
def initialize(name, search)
|
5
|
+
condition = search.class._conditions[name.to_sym]
|
6
|
+
raise ConditionNotFoundError.new(search, name) unless condition
|
7
|
+
|
8
|
+
@condition_class = condition.class
|
9
|
+
@condition_config = condition.attributes.dup
|
10
|
+
@search = search
|
11
|
+
@name = name
|
12
|
+
end
|
13
|
+
|
14
|
+
def or(values)
|
15
|
+
set(values: values, and: false)
|
16
|
+
end
|
17
|
+
alias :in :or
|
18
|
+
|
19
|
+
def and(values)
|
20
|
+
set(values: values, and: true)
|
21
|
+
end
|
22
|
+
alias :all :and
|
23
|
+
|
24
|
+
def analyzed(value)
|
25
|
+
set(values: value, search_analyzed: true)
|
26
|
+
end
|
27
|
+
|
28
|
+
def not(values)
|
29
|
+
set(values: values, not: true)
|
30
|
+
end
|
31
|
+
alias :not_in :not
|
32
|
+
|
33
|
+
def gte(value)
|
34
|
+
merge(from_eq: value)
|
35
|
+
end
|
36
|
+
|
37
|
+
def gt(value)
|
38
|
+
merge(from: value)
|
39
|
+
end
|
40
|
+
|
41
|
+
def lte(value)
|
42
|
+
merge(to_eq: value)
|
43
|
+
end
|
44
|
+
|
45
|
+
def lt(value)
|
46
|
+
merge(to: value)
|
47
|
+
end
|
48
|
+
|
49
|
+
def within(range)
|
50
|
+
set(from: range.first, to: range.last)
|
51
|
+
end
|
52
|
+
|
53
|
+
def within_eq(range)
|
54
|
+
set(from_eq: range.first, to_eq: range.last)
|
55
|
+
end
|
56
|
+
|
57
|
+
def eq(value)
|
58
|
+
set(values: value)
|
59
|
+
end
|
60
|
+
|
61
|
+
def autocomplete(value)
|
62
|
+
set(values: value, autocomplete: true)
|
63
|
+
end
|
64
|
+
|
65
|
+
def starts_with(value)
|
66
|
+
set(values: value, prefix: true)
|
67
|
+
end
|
68
|
+
|
69
|
+
def any_text(value)
|
70
|
+
set(values: value, any_text: true)
|
71
|
+
end
|
72
|
+
|
73
|
+
def set(payload)
|
74
|
+
payload = {values: payload} unless payload.is_a?(Hash)
|
75
|
+
condition = @condition_class.new(@condition_config.merge(payload))
|
76
|
+
@search.conditions[@name] = condition
|
77
|
+
@search
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def merge(payload)
|
83
|
+
existing = @search.conditions[@name]
|
84
|
+
existing_attrs = {}
|
85
|
+
existing_attrs = existing.attributes if existing
|
86
|
+
merged = existing_attrs.merge(payload)
|
87
|
+
set(merged)
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Trample
|
2
|
+
class ConditionNotFoundError < StandardError
|
3
|
+
|
4
|
+
def initialize(search, condition_name)
|
5
|
+
@search = search
|
6
|
+
@condition_name = condition_name
|
7
|
+
end
|
8
|
+
|
9
|
+
def message
|
10
|
+
"Could not find condition #{@condition_name} in search #{@search.class}"
|
11
|
+
end
|
12
|
+
|
13
|
+
end
|
14
|
+
|
15
|
+
class AggregationNotDefinedError < StandardError
|
16
|
+
|
17
|
+
def initialize(search, agg_name)
|
18
|
+
@search = search
|
19
|
+
@agg_name = agg_name
|
20
|
+
end
|
21
|
+
|
22
|
+
def message
|
23
|
+
"Could not find facet #{@agg_name} in search #{@search.class}"
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Trample
|
2
|
+
class Metadata
|
3
|
+
include Virtus.model
|
4
|
+
extend Forwardable
|
5
|
+
|
6
|
+
class Pagination
|
7
|
+
include Virtus.model
|
8
|
+
|
9
|
+
attribute :total, Integer
|
10
|
+
attribute :current_page, Integer, default: 1
|
11
|
+
attribute :per_page, Integer, default: 20
|
12
|
+
end
|
13
|
+
|
14
|
+
class Sort
|
15
|
+
include Virtus.model
|
16
|
+
|
17
|
+
attribute :att, String
|
18
|
+
attribute :dir, String
|
19
|
+
end
|
20
|
+
|
21
|
+
class Records
|
22
|
+
include Virtus.model
|
23
|
+
|
24
|
+
attribute :load, Boolean, default: false
|
25
|
+
attribute :includes, Hash, default: {}
|
26
|
+
end
|
27
|
+
|
28
|
+
attribute :records, Records, default: ->(_,_) { Records.new }
|
29
|
+
attribute :pagination, Pagination, default: ->(_,_) { Pagination.new }
|
30
|
+
attribute :took, Integer
|
31
|
+
attribute :sort, Array[Sort]
|
32
|
+
|
33
|
+
def_delegators :pagination, :total, :current_page, :per_page
|
34
|
+
def_delegator :sort, :att, :sort_att
|
35
|
+
def_delegator :sort, :dir, :sort_dir
|
36
|
+
|
37
|
+
def total_pages
|
38
|
+
(total.to_f / per_page.to_f).ceil
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,176 @@
|
|
1
|
+
module Trample
|
2
|
+
class Search
|
3
|
+
include Serializable
|
4
|
+
include Virtus.model
|
5
|
+
|
6
|
+
attribute :id, String, default: ->(instance, attr) { SecureRandom.uuid }
|
7
|
+
attribute :conditions, Hash[Symbol => Condition], default: ->(search, attr) { {} }
|
8
|
+
attribute :aggregations, Array[Aggregation], default: ->(search, attr) { {} }
|
9
|
+
attribute :results, Array
|
10
|
+
attribute :metadata, Metadata, default: ->(search, attr) { Metadata.new }
|
11
|
+
|
12
|
+
class << self
|
13
|
+
attr_accessor :_conditions, :_aggs
|
14
|
+
attr_reader :_models
|
15
|
+
end
|
16
|
+
self._conditions = {}
|
17
|
+
self._aggs = {}
|
18
|
+
|
19
|
+
def self.inherited(klass)
|
20
|
+
super
|
21
|
+
klass._conditions = self._conditions.dup
|
22
|
+
klass._aggs = self._aggs.dup
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.condition(name, attrs = {})
|
26
|
+
attrs.merge!(name: name)
|
27
|
+
@_conditions[name] = Condition.new(attrs)
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.aggregation(name, attrs = {})
|
31
|
+
attrs.merge!(name: name)
|
32
|
+
attrs[:order] = @_aggs.keys.length
|
33
|
+
@_aggs[name] = Aggregation.new(attrs)
|
34
|
+
yield @_aggs[name] if block_given?
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.model(*klasses)
|
38
|
+
@_models = klasses
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.paginate(page_params)
|
42
|
+
instance = new
|
43
|
+
instance.paginate(page_params)
|
44
|
+
end
|
45
|
+
|
46
|
+
def paginate(page_params)
|
47
|
+
page_params ||= {}
|
48
|
+
metadata.pagination.current_page = page_params[:number] if page_params[:number]
|
49
|
+
metadata.pagination.per_page = page_params[:size] if page_params[:size]
|
50
|
+
self
|
51
|
+
end
|
52
|
+
|
53
|
+
def sort(*fields)
|
54
|
+
return self if fields.empty?
|
55
|
+
|
56
|
+
sorts = fields.map do |f|
|
57
|
+
if f.to_s.starts_with?('-')
|
58
|
+
f.sub!('-','')
|
59
|
+
{att: f, dir: :desc}
|
60
|
+
else
|
61
|
+
{att: f, dir: :asc}
|
62
|
+
end
|
63
|
+
end
|
64
|
+
self.metadata.sort = sorts
|
65
|
+
self
|
66
|
+
end
|
67
|
+
|
68
|
+
def condition(name)
|
69
|
+
ConditionProxy.new(name, self)
|
70
|
+
end
|
71
|
+
|
72
|
+
def includes(includes)
|
73
|
+
self.metadata.records[:includes] = includes
|
74
|
+
end
|
75
|
+
|
76
|
+
# todo refactor...
|
77
|
+
def agg(*names_or_payloads)
|
78
|
+
names_or_payloads.each do |name_or_payload|
|
79
|
+
name = name_or_payload
|
80
|
+
selections = []
|
81
|
+
if name_or_payload.is_a?(Hash)
|
82
|
+
name = name_or_payload.keys.first if name_or_payload.is_a?(Hash)
|
83
|
+
selections = Array(name_or_payload.values.first)
|
84
|
+
end
|
85
|
+
template = self.class._aggs[name.to_sym]
|
86
|
+
raise AggregationNotDefinedError.new(self, name) unless template
|
87
|
+
agg = self.aggregations.find { |a| a.name.to_sym == name.to_sym }
|
88
|
+
|
89
|
+
if agg.nil?
|
90
|
+
# N.B. deep dup so buckets don't mutate
|
91
|
+
agg = Aggregation.new(deep_dup(template.attributes).merge(name: name.to_sym))
|
92
|
+
agg.bucket_sort = template.bucket_sort
|
93
|
+
self.aggregations << agg
|
94
|
+
end
|
95
|
+
|
96
|
+
selections.each do |key|
|
97
|
+
bucket = agg.find_or_initialize_bucket(key)
|
98
|
+
bucket.selected = true
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
self
|
103
|
+
end
|
104
|
+
|
105
|
+
# N.B rails may send nil here instead of empty array
|
106
|
+
def aggregations=(aggregation_array)
|
107
|
+
aggregation_array ||= []
|
108
|
+
super([])
|
109
|
+
|
110
|
+
aggregation_array.each do |aggregation_hash|
|
111
|
+
if aggregation_hash[:buckets] # rails converting [] to nil
|
112
|
+
selections = aggregation_hash[:buckets].select { |b| !!b[:selected] }.map { |b| b[:key] }
|
113
|
+
agg(aggregation_hash[:name].to_sym => selections)
|
114
|
+
else
|
115
|
+
agg(aggregation_hash[:name].to_sym => [])
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def aggregations
|
121
|
+
@aggregations.sort! { |a, b| a.order <=> b.order }
|
122
|
+
@aggregations
|
123
|
+
end
|
124
|
+
|
125
|
+
def conditions=(hash)
|
126
|
+
super({})
|
127
|
+
hash.each_pair do |name, value|
|
128
|
+
condition(name).set(value)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def backend
|
133
|
+
@backend ||= Backend::Searchkick.new(metadata, self.class._models)
|
134
|
+
end
|
135
|
+
|
136
|
+
def query!
|
137
|
+
@records = nil
|
138
|
+
hash = backend.query!(conditions, aggregations)
|
139
|
+
self.metadata.took = hash[:took]
|
140
|
+
self.metadata.pagination.total = hash[:total]
|
141
|
+
self.results = hash[:results]
|
142
|
+
if !!metadata.records[:load]
|
143
|
+
records!
|
144
|
+
else
|
145
|
+
self.results
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
# Todo only works for single-model search atm
|
150
|
+
# N.B. preserves sorting
|
151
|
+
def records
|
152
|
+
@records ||= begin
|
153
|
+
queried = self.class._models.first.where(id: results.map(&:_id))
|
154
|
+
queried = queried.includes(metadata.records[:includes])
|
155
|
+
[].tap do |sorted|
|
156
|
+
results.each do |result|
|
157
|
+
model = queried.find { |m| m.id.to_s == result.id.to_s }
|
158
|
+
sorted << model
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def records!
|
165
|
+
@records = nil
|
166
|
+
records
|
167
|
+
end
|
168
|
+
|
169
|
+
private
|
170
|
+
|
171
|
+
def deep_dup(o)
|
172
|
+
Marshal.load(Marshal.dump(o))
|
173
|
+
end
|
174
|
+
|
175
|
+
end
|
176
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
# Convenience methods so you don't have to write tons of
|
2
|
+
# swagger documentation for every trample-based endpoint
|
3
|
+
|
4
|
+
module Trample
|
5
|
+
module Swagger
|
6
|
+
|
7
|
+
CONDITION_OPTION_WHITELIST = [
|
8
|
+
:single,
|
9
|
+
:range,
|
10
|
+
:prefix,
|
11
|
+
:autocomplete,
|
12
|
+
:search_analyzed,
|
13
|
+
:not,
|
14
|
+
:and,
|
15
|
+
:any_text
|
16
|
+
]
|
17
|
+
|
18
|
+
def trample_swagger_schema
|
19
|
+
swagger_schema :TrampleSearch do
|
20
|
+
property :data do
|
21
|
+
key :type, :object
|
22
|
+
|
23
|
+
property :attributes do
|
24
|
+
key :type, :object
|
25
|
+
|
26
|
+
property :conditions do
|
27
|
+
key :type, :object
|
28
|
+
end
|
29
|
+
|
30
|
+
property :metadata do
|
31
|
+
key :type, :object
|
32
|
+
|
33
|
+
property :pagination do
|
34
|
+
key :type, :object
|
35
|
+
key :example, {current_page: 1, per_page: 20}
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
property :aggregations do
|
40
|
+
key :type, :array
|
41
|
+
key :example, []
|
42
|
+
|
43
|
+
items do
|
44
|
+
key :type, :object
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
swagger_schema :TrampleSearchResponse do
|
52
|
+
allOf do
|
53
|
+
schema do
|
54
|
+
key :'$ref', :TrampleSearch
|
55
|
+
end
|
56
|
+
|
57
|
+
schema do
|
58
|
+
property :results do
|
59
|
+
key :type, :array
|
60
|
+
|
61
|
+
items do
|
62
|
+
key :type, :object
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def trample_swagger(search_class, path)
|
71
|
+
swagger_path "#{path}/new" do
|
72
|
+
operation :get do
|
73
|
+
key :description, "Instantiate default search. See the corresponding PUT operation for valid inputs."
|
74
|
+
key :tags, ['search']
|
75
|
+
|
76
|
+
response 200 do
|
77
|
+
key :description, 'Trample response'
|
78
|
+
schema do
|
79
|
+
key :'$ref', :TrampleSearchResponse
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
swagger_path "#{path}/{id}" do
|
86
|
+
operation :put do
|
87
|
+
description = "<p>Trample search <a target='_blank' href='http://richmolj.github.io/trample'>View Full Trample Documentation</a></p><p><strong>Conditions:</strong></p><ul>"
|
88
|
+
search_class._conditions.each_pair do |name, condition|
|
89
|
+
attrs = condition.attributes.select { |k,v| !!v }.map { |k,v| k }
|
90
|
+
attrs.select! { |a| CONDITION_OPTION_WHITELIST.include?(a) }
|
91
|
+
attrs = attrs.present? ? "(#{attrs.join(', ')})" : ''
|
92
|
+
description << "<li>#{name} #{attrs}</li>"
|
93
|
+
end
|
94
|
+
description << "</ul>"
|
95
|
+
|
96
|
+
if search_class._aggs.present?
|
97
|
+
description << "<p><strong>Aggregations:</strong></p><ul>"
|
98
|
+
search_class._aggs.each_pair do |name, agg|
|
99
|
+
description << "<li>#{name}</li>"
|
100
|
+
end
|
101
|
+
description << "</ul>"
|
102
|
+
end
|
103
|
+
|
104
|
+
key :description, description
|
105
|
+
key :tags, ['search']
|
106
|
+
|
107
|
+
parameter paramType: :path do
|
108
|
+
key :name, :id
|
109
|
+
key :type, :integer
|
110
|
+
key :default, SecureRandom.uuid
|
111
|
+
end
|
112
|
+
|
113
|
+
parameter do
|
114
|
+
key :name, :data
|
115
|
+
key :in, :body
|
116
|
+
|
117
|
+
schema do
|
118
|
+
key :'$ref', :TrampleSearch
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
response 200 do
|
123
|
+
key :description, 'Trample response'
|
124
|
+
schema do
|
125
|
+
key :'$ref', :TrampleSearchResponse
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
end
|
133
|
+
end
|