trample_search 0.14.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,9 @@
1
+ module Trample
2
+ class Railtie < Rails::Railtie
3
+
4
+ config.before_initialize do
5
+ Trample::Search.extend ActiveModel::Naming
6
+ end
7
+
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Trample
2
+ class Results
3
+ include Virtus.model
4
+
5
+ attribute :total, Integer
6
+ attribute :took, Integer
7
+ attribute :entries, Array
8
+ end
9
+ 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,7 @@
1
+ module Trample
2
+ module Serializable
3
+ def read_attribute_for_serialization(name)
4
+ attributes[name]
5
+ end
6
+ end
7
+ 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