create_custom_attributes 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +12 -0
  4. data/Rakefile +35 -0
  5. data/lib/custom_attributes.rb +64 -0
  6. data/lib/custom_attributes/acts_as/acts_as_custom_field.rb +167 -0
  7. data/lib/custom_attributes/acts_as/acts_as_custom_value.rb +53 -0
  8. data/lib/custom_attributes/acts_as/acts_as_customizable.rb +216 -0
  9. data/lib/custom_attributes/api/custom_attributes_controller_helper.rb +32 -0
  10. data/lib/custom_attributes/concerns/searchable.rb +64 -0
  11. data/lib/custom_attributes/custom_attributes_api_helper.rb +6 -0
  12. data/lib/custom_attributes/custom_field_value.rb +54 -0
  13. data/lib/custom_attributes/field_type.rb +153 -0
  14. data/lib/custom_attributes/field_types/bool_field_type.rb +40 -0
  15. data/lib/custom_attributes/field_types/date_field_type.rb +28 -0
  16. data/lib/custom_attributes/field_types/float_field_type.rb +19 -0
  17. data/lib/custom_attributes/field_types/int_field_type.rb +19 -0
  18. data/lib/custom_attributes/field_types/list.rb +57 -0
  19. data/lib/custom_attributes/field_types/list_field_type.rb +36 -0
  20. data/lib/custom_attributes/field_types/numeric.rb +5 -0
  21. data/lib/custom_attributes/field_types/string_field_type.rb +5 -0
  22. data/lib/custom_attributes/field_types/text_field_type.rb +10 -0
  23. data/lib/custom_attributes/field_types/unbounded.rb +16 -0
  24. data/lib/custom_attributes/fluent_search_query.rb +168 -0
  25. data/lib/custom_attributes/search_query.rb +229 -0
  26. data/lib/custom_attributes/search_query_field.rb +48 -0
  27. data/lib/custom_attributes/version.rb +3 -0
  28. data/lib/tasks/custom_attributes_tasks.rake +4 -0
  29. data/lib/tasks/elasticsearch_tasks.rake +1 -0
  30. metadata +144 -0
@@ -0,0 +1,36 @@
1
+ module CustomAttributes
2
+ class ListFieldType < List
3
+ include Singleton
4
+
5
+ def possible_custom_value_options(custom_value)
6
+ options = possible_values_options(custom_value.custom_field)
7
+ missing = [custom_value.value].flatten.reject(&:blank?) - options
8
+ if missing.any?
9
+ options += missing
10
+ end
11
+ options
12
+ end
13
+
14
+ def possible_values_options(custom_field, object=nil)
15
+ custom_field.possible_values
16
+ end
17
+
18
+ def validate_custom_field(custom_field)
19
+ errors = []
20
+ errors << [:possible_values, :blank] if custom_field.possible_values.blank?
21
+ errors << [:possible_values, :invalid] unless custom_field.possible_values.is_a? Array
22
+ errors
23
+ end
24
+
25
+ def validate_custom_value(custom_value)
26
+ values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
27
+ invalid_values = values - Array.wrap(custom_value.value_was) - custom_value.custom_field.possible_values
28
+ if invalid_values.any?
29
+ [::I18n.t('activerecord.errors.messages.inclusion')]
30
+ else
31
+ []
32
+ end
33
+ end
34
+
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ module CustomAttributes
2
+ class Numeric < Unbounded
3
+
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module CustomAttributes
2
+ class StringFieldType < Unbounded
3
+ include Singleton
4
+ end
5
+ end
@@ -0,0 +1,10 @@
1
+ module CustomAttributes
2
+ class TextFieldType < Unbounded
3
+ include Singleton
4
+
5
+ def edit_tag(view, tag_id, tag_name, custom_value, options={})
6
+ view.text_area_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :rows => 8))
7
+ end
8
+
9
+ end
10
+ end
@@ -0,0 +1,16 @@
1
+ module CustomAttributes
2
+ # This class is only to be extended by types that need length validation
3
+ class Unbounded < FieldType
4
+ def validate_single_value(custom_field, value, customizable = nil)
5
+ errs = super
6
+ value = value.to_s
7
+ if custom_field.min_length && value.length < custom_field.min_length
8
+ errs << ::I18n.t('activerecord.errors.messages.too_short', count: custom_field.min_length)
9
+ end
10
+ if custom_field.max_length && custom_field.max_length > 0 && value.length > custom_field.max_length
11
+ errs << ::I18n.t('activerecord.errors.messages.too_long', count: custom_field.max_length)
12
+ end
13
+ errs
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,168 @@
1
+ require 'custom_attributes/search_query'
2
+
3
+ module CustomAttributes
4
+ class FluentSearchQuery
5
+ def initialize(customizable)
6
+ @search_query = CustomAttributes::SearchQuery.new
7
+ @search_query.customizable = customizable unless customizable.nil?
8
+
9
+ @raw_results = nil
10
+ @field_list = {}
11
+ @field_queries = {}
12
+ @field_config = {}
13
+ end
14
+
15
+ def query(query)
16
+ @search_query.query = query
17
+
18
+ self
19
+ end
20
+
21
+ def field_list(field_list)
22
+ @field_list = Hash[field_list.map do |type, fields|
23
+ fields = fields.split(',') if fields.is_a? String
24
+ [type, fields]
25
+ end]
26
+
27
+ self
28
+ end
29
+
30
+ def field_queries(field_queries)
31
+ @field_queries = field_queries
32
+
33
+ self
34
+ end
35
+
36
+ def field_config(field_config)
37
+ @field_config = field_config
38
+
39
+ self
40
+ end
41
+
42
+ def filter_by(field_list)
43
+ @search_query.filter_by.merge! field_list
44
+
45
+ self
46
+ end
47
+
48
+ def sort_by(field_list)
49
+ @search_query.sort_by.merge! field_list
50
+
51
+ self
52
+ end
53
+
54
+ def match_any
55
+ @search_query.match_any = true
56
+
57
+ self
58
+ end
59
+
60
+ def page(page)
61
+ @search_query.page = page.to_i
62
+
63
+ self
64
+ end
65
+
66
+ def per_page(per_page)
67
+ @search_query.per_page = per_page.to_i
68
+
69
+ self
70
+ end
71
+
72
+ # Performs the search
73
+ def search
74
+ @raw_results = @search_query.customizable.class.__elasticsearch__.search(query_hash)
75
+
76
+ self
77
+ end
78
+
79
+ # Returns the results after search has been performed
80
+ # Call search() first!
81
+ def results
82
+ return nil if @raw_results.nil?
83
+
84
+ begin
85
+ @raw_results.count
86
+
87
+ @raw_results.results
88
+ rescue Faraday::ConnectionFailed => e
89
+ @search_query.customizable.class.handle_search_connection_error(e)
90
+ end
91
+ end
92
+
93
+ # Fetches the ActiveRecord database records
94
+ # Does not perform search, so call search() first!
95
+ def records
96
+ return nil if @raw_results.nil?
97
+
98
+ begin
99
+ @raw_results.count
100
+
101
+ @raw_results.records
102
+ rescue Faraday::ConnectionFailed => e
103
+ @search_query.customizable.class.handle_search_connection_error(e)
104
+ end
105
+ end
106
+
107
+ # Performs the search and returns the count of results
108
+ def count
109
+ search.results.count
110
+ rescue Faraday::ConnectionFailed => e
111
+ @search_query.customizable.class.handle_search_connection_error(e)
112
+ end
113
+
114
+ # Use at own risk, no Connection Exception handling here
115
+ def raw
116
+ @raw_results
117
+ end
118
+
119
+ # Return the hash that is conform to elasticsearch's DSL
120
+ # Does not perform a search query!
121
+ def query_hash
122
+ process_field_list
123
+
124
+ @search_query.build
125
+ end
126
+
127
+ private
128
+
129
+ # Combine field list, field queries and field config into one field list
130
+ def process_field_list
131
+ # apply default fields if no user specified fields are present
132
+ if @field_list.empty?
133
+ field_list = @search_query.default_fields
134
+ else
135
+ field_list = Hash[@field_list.map { |type, list| [type, Hash[list.map { |v| [v.to_sym, {}] }]] }]
136
+ end
137
+
138
+ # field list is in the format {fields: {field1: {}, field2: {},...}, custom_fields: {field1: {}, ...}}
139
+ # this mapper has the purpose to populate the hash with additional configuration on a per-field basis
140
+ #
141
+ # type: either :fields or :custom_fields
142
+ # list: a hash of fields with options
143
+ field_list.map do |type, list|
144
+ [
145
+ type,
146
+ Hash[list.map do |slug, options|
147
+ # apply options already present in the field_list (possible default settings)
148
+ field_options = options
149
+
150
+ # overwrite query (fulltext search) with field specific setting
151
+ if @field_queries[type] && @field_queries[type][slug]
152
+ field_options[:query] = @field_queries[type][slug]
153
+ end
154
+
155
+ # add other configuration such as fuzziness, operator, ...
156
+ if @field_config[type] && @field_config[type][slug]
157
+ field_options.merge! @field_config[type][slug]
158
+ end
159
+
160
+ [slug, field_options]
161
+ end]
162
+ ]
163
+ end
164
+
165
+ @search_query.field_list = field_list
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,229 @@
1
+ require 'custom_attributes/search_query_field'
2
+
3
+ module CustomAttributes
4
+ # Builds a query in elastic search DSL
5
+ class SearchQuery
6
+ attr_accessor :customizable, :query, :field_list, :defaults, :sort_by, :page, :per_page, :match_any, :filter_by
7
+
8
+ def build
9
+ {
10
+ query: subquery,
11
+ from: from,
12
+ size: per_page,
13
+ sort: sort_hash_to_array
14
+ }
15
+ end
16
+
17
+ def from
18
+ (page - 1) * per_page
19
+ end
20
+
21
+ # fulltext search query, by default applied to all fields
22
+ def query
23
+ @query ||= '*'
24
+ end
25
+
26
+ def page
27
+ @page ||= 1
28
+ end
29
+
30
+ def per_page
31
+ @per_page ||= 20
32
+ end
33
+
34
+ def filter_by
35
+ @filter_by ||= {}
36
+ end
37
+
38
+ def sort_by
39
+ @sort_by ||= {}
40
+ end
41
+
42
+ def field_list
43
+ @field_list ||= {}
44
+ end
45
+
46
+ def defaults
47
+ {
48
+ query: query,
49
+ fuzziness: 0
50
+ }
51
+ end
52
+
53
+ def match_any?
54
+ @match_any
55
+ end
56
+
57
+ def default_fields
58
+ default_fields = {
59
+ custom_fields: Hash[customizable.available_custom_fields
60
+ .select { |cf| cf.searchable == true }
61
+ .map { |cf| [cf.slug.to_sym, {}] }]
62
+ }
63
+
64
+ if customizable.present?
65
+ default_fields[:fields] = Hash[
66
+ customizable.class.default_fields.map { |f| [f.to_sym, {}] }
67
+ ]
68
+ end
69
+
70
+ default_fields
71
+ end
72
+
73
+ private
74
+
75
+ def subquery
76
+ filter = filter_hash_to_term_array + [{ term: { visible_in_search: true } }]
77
+
78
+ # match all documents if no particular query isset
79
+ if query == '*' && field_list.empty?
80
+ return {
81
+ bool: {
82
+ must: [
83
+ {
84
+ match_all: {}
85
+ }
86
+ ],
87
+ filter: filter
88
+ }
89
+ }
90
+ end
91
+
92
+ {
93
+ bool: {
94
+ filter: filter
95
+ }.merge(match_any_decorator(collect_queries))
96
+ }
97
+ end
98
+
99
+ def collect_queries
100
+ # build field specific queries
101
+ queries = model_field_queries + custom_value_queries
102
+
103
+ # if there are not field specific queries, match all fields
104
+ if queries.count.zero?
105
+ if query == '*'
106
+ { match_all: {} }
107
+ else
108
+ { match: { _all: query } }
109
+ end
110
+ else
111
+ queries
112
+ end
113
+ end
114
+
115
+ def custom_value_queries
116
+ return [] if filter_only_custom_fields(field_list).count.zero?
117
+
118
+ filter_only_custom_fields(field_list).map do |field_slug, field|
119
+ custom_query_scaffold(field.to_query_hash, field_slug) unless field.to_query_hash.empty?
120
+ end.compact
121
+ end
122
+
123
+ def model_field_queries
124
+ return [] if filter_out_custom_fields(field_list).count.zero?
125
+
126
+ queries = filter_out_custom_fields(field_list).map do |field_slug, field|
127
+ { match: { field_slug => field.to_query_hash } } unless field.to_query_hash.empty?
128
+ end.compact
129
+
130
+ return [] if queries.empty?
131
+
132
+ [{
133
+ bool: {
134
+ should: queries,
135
+ minimum_should_match: 1
136
+ }
137
+ }]
138
+ end
139
+
140
+ def sort_hash_to_array
141
+ hash_to_array(sort_by) do |type, field_slug, field_data|
142
+ if type == :custom_fields
143
+ next unless find_custom_field_by_slug(field_slug).sortable
144
+ {
145
+ 'custom_values.value.raw' => {
146
+ order: field_data || 'asc',
147
+ nested_path: 'custom_values',
148
+ nested_filter: {
149
+ term: { 'custom_values.custom_field_id' => resolve_custom_field_id(field_slug) }
150
+ }
151
+ }
152
+ }
153
+ else
154
+ { field_slug.to_s => { order: field_data } }
155
+ end
156
+ end
157
+ end
158
+
159
+ def filter_hash_to_term_array
160
+ hash_to_array(filter_by) do |type, field_slug, field_data|
161
+ if type == :custom_fields
162
+ custom_query_scaffold(field_data, field_slug, true)
163
+ else
164
+ key = 'term'
165
+ key = key.pluralize if field_data.is_a? Array
166
+
167
+ { key.to_sym => { field_slug.to_s => field_data } }
168
+ end
169
+ end
170
+ end
171
+
172
+ def filter_out_custom_fields(fields)
173
+ return {} if fields[:fields].nil?
174
+
175
+ Hash[fields[:fields].map { |k, v| [k, CustomAttributes::SearchQueryField.new(v, defaults)] }]
176
+ end
177
+
178
+ def filter_only_custom_fields(fields)
179
+ return {} if fields[:custom_fields].nil?
180
+
181
+ Hash[fields[:custom_fields].map { |k, v| [k, CustomAttributes::SearchQueryField.new(v, defaults)] }]
182
+ end
183
+
184
+ def resolve_custom_field_id(field_slug)
185
+ find_custom_field_by_slug(field_slug).try(:id) || raise('Field id not found')
186
+ end
187
+
188
+ def find_custom_field_by_slug(field_slug)
189
+ customizable.available_custom_fields.find { |cf| cf.slug == field_slug.to_s } || raise('Field not found')
190
+ end
191
+
192
+ def hash_to_array(field_list)
193
+ field_list.map do |type, fields|
194
+ fields.map do |field_slug, field_data|
195
+ yield(type, field_slug, field_data)
196
+ end
197
+ end.flatten.compact
198
+ end
199
+
200
+ def match_any_decorator(query_array)
201
+ if !match_any?
202
+ { must: query_array }
203
+ else
204
+ { should: query_array, minimum_should_match: 1 }
205
+ end
206
+ end
207
+
208
+ def custom_query_scaffold(query, field_slug, filter = false)
209
+ condition = { match: { 'custom_values.value' => query } }
210
+ condition = { term: { 'custom_values.value.raw' => query } } if filter
211
+
212
+ {
213
+ nested: {
214
+ path: 'custom_values',
215
+ query: {
216
+ bool: {
217
+ must: [
218
+ condition,
219
+ {
220
+ term: { 'custom_values.custom_field_id' => resolve_custom_field_id(field_slug) }
221
+ }
222
+ ]
223
+ }
224
+ }
225
+ }
226
+ }
227
+ end
228
+ end
229
+ end