create_custom_attributes 0.5.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.
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