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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +12 -0
- data/Rakefile +35 -0
- data/lib/custom_attributes.rb +64 -0
- data/lib/custom_attributes/acts_as/acts_as_custom_field.rb +167 -0
- data/lib/custom_attributes/acts_as/acts_as_custom_value.rb +53 -0
- data/lib/custom_attributes/acts_as/acts_as_customizable.rb +216 -0
- data/lib/custom_attributes/api/custom_attributes_controller_helper.rb +32 -0
- data/lib/custom_attributes/concerns/searchable.rb +64 -0
- data/lib/custom_attributes/custom_attributes_api_helper.rb +6 -0
- data/lib/custom_attributes/custom_field_value.rb +54 -0
- data/lib/custom_attributes/field_type.rb +153 -0
- data/lib/custom_attributes/field_types/bool_field_type.rb +40 -0
- data/lib/custom_attributes/field_types/date_field_type.rb +28 -0
- data/lib/custom_attributes/field_types/float_field_type.rb +19 -0
- data/lib/custom_attributes/field_types/int_field_type.rb +19 -0
- data/lib/custom_attributes/field_types/list.rb +57 -0
- data/lib/custom_attributes/field_types/list_field_type.rb +36 -0
- data/lib/custom_attributes/field_types/numeric.rb +5 -0
- data/lib/custom_attributes/field_types/string_field_type.rb +5 -0
- data/lib/custom_attributes/field_types/text_field_type.rb +10 -0
- data/lib/custom_attributes/field_types/unbounded.rb +16 -0
- data/lib/custom_attributes/fluent_search_query.rb +168 -0
- data/lib/custom_attributes/search_query.rb +229 -0
- data/lib/custom_attributes/search_query_field.rb +48 -0
- data/lib/custom_attributes/version.rb +3 -0
- data/lib/tasks/custom_attributes_tasks.rake +4 -0
- data/lib/tasks/elasticsearch_tasks.rake +1 -0
- 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,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
|