trample_search 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|