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.
@@ -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