forty_facets 0.0.10 → 0.0.11
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/forty_facets/facet_search.rb +15 -25
- data/lib/forty_facets/filter/facet_filter_definition.rb +163 -0
- data/lib/forty_facets/filter/range_filter_definition.rb +6 -2
- data/lib/forty_facets/filter/text_filter_definition.rb +3 -2
- data/lib/forty_facets/filter.rb +6 -26
- data/lib/forty_facets/filter_definition.rb +49 -3
- data/lib/forty_facets/version.rb +1 -1
- data/lib/forty_facets.rb +1 -4
- data/test/fixtures.rb +35 -2
- data/test/smoke_test.rb +45 -5
- metadata +3 -6
- data/lib/forty_facets/filter/attribute_filter_definition.rb +0 -46
- data/lib/forty_facets/filter/belongs_to_chain_filter_definition.rb +0 -79
- data/lib/forty_facets/filter/belongs_to_filter_definition.rb +0 -58
- data/lib/forty_facets/filter/has_many_filter_definition.rb +0 -74
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 008e646b5375a8907af87b307c754004ce77ff5f
|
4
|
+
data.tar.gz: c027d5938ed8a9b6c448d7252664a4b9446c864d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 29c55e4685443146480d56e722fc8e4aa7ae4a38e981b76ee9058389a0cd00fd5b86fd2313a55898d673cba3fca8e2302e61ba961b5ca441fc05f90097705de8
|
7
|
+
data.tar.gz: ebf90e00aff854b1e07e49a4852a4dd10c40034285df247dd2e5ab92ac560b4039b9e48bc77f3c0765868f99757d82e3838cb29623f712c939b85c464ff4f1a4
|
@@ -19,33 +19,24 @@ module FortyFacets
|
|
19
19
|
attr_reader :filters, :orders
|
20
20
|
|
21
21
|
class << self
|
22
|
-
def model(
|
23
|
-
|
22
|
+
def model(model)
|
23
|
+
if model.is_a? Class
|
24
|
+
@root_class = model
|
25
|
+
else
|
26
|
+
@root_class = Kernel.const_get(model)
|
27
|
+
end
|
24
28
|
end
|
25
29
|
|
26
|
-
def text(
|
27
|
-
definitions << TextFilterDefinition.new(self,
|
30
|
+
def text(path, opts = {})
|
31
|
+
definitions << TextFilterDefinition.new(self, path, opts)
|
28
32
|
end
|
29
33
|
|
30
|
-
def range(
|
31
|
-
definitions << RangeFilterDefinition.new(self,
|
34
|
+
def range(path, opts = {})
|
35
|
+
definitions << RangeFilterDefinition.new(self, path, opts)
|
32
36
|
end
|
33
37
|
|
34
|
-
def facet(
|
35
|
-
|
36
|
-
definitions << BelongsToChainFilterDefinition.new(self, model_field, opts)
|
37
|
-
else
|
38
|
-
reflection = self.root_class.reflect_on_association(model_field)
|
39
|
-
if reflection
|
40
|
-
if reflection.macro == :belongs_to
|
41
|
-
definitions << BelongsToFilterDefinition.new(self, model_field, opts)
|
42
|
-
else
|
43
|
-
definitions << HasManyFilterDefinition.new(self, model_field, opts)
|
44
|
-
end
|
45
|
-
else
|
46
|
-
definitions << AttributeFilterDefinition.new(self, model_field, opts)
|
47
|
-
end
|
48
|
-
end
|
38
|
+
def facet(path, opts = {})
|
39
|
+
definitions << FacetFilterDefinition.new(self, path, opts)
|
49
40
|
end
|
50
41
|
|
51
42
|
def orders(name_and_order_options)
|
@@ -57,8 +48,7 @@ module FortyFacets
|
|
57
48
|
end
|
58
49
|
|
59
50
|
def root_class
|
60
|
-
raise
|
61
|
-
Kernel.const_get(@model_name)
|
51
|
+
@root_class || raise('No model class given')
|
62
52
|
end
|
63
53
|
|
64
54
|
def root_scope
|
@@ -96,7 +86,7 @@ module FortyFacets
|
|
96
86
|
end
|
97
87
|
|
98
88
|
def filter(filter_name)
|
99
|
-
filter = @filters.find { |f| f.
|
89
|
+
filter = @filters.find { |f| f.definition.path == [filter_name].flatten }
|
100
90
|
raise "Unknown filter #{filter_name}" unless filter
|
101
91
|
filter
|
102
92
|
end
|
@@ -119,7 +109,7 @@ module FortyFacets
|
|
119
109
|
|
120
110
|
def params
|
121
111
|
params = @filters.inject({}) do |sum, filter|
|
122
|
-
sum[filter.
|
112
|
+
sum[filter.definition.request_param] = filter.value.dup unless filter.empty?
|
123
113
|
sum
|
124
114
|
end
|
125
115
|
params[:order] = order.definition.request_value if order
|
@@ -0,0 +1,163 @@
|
|
1
|
+
module FortyFacets
|
2
|
+
class FacetFilterDefinition < FilterDefinition
|
3
|
+
|
4
|
+
class FacetFilter < Filter
|
5
|
+
def values
|
6
|
+
@values ||= Array.wrap(value).sort.uniq
|
7
|
+
end
|
8
|
+
|
9
|
+
protected
|
10
|
+
|
11
|
+
def order_facet!(facet)
|
12
|
+
order_accessor = definition.options[:order]
|
13
|
+
if order_accessor
|
14
|
+
if order_accessor.is_a?(Proc)
|
15
|
+
facet.sort_by!{|facet_value| order_accessor.call(facet_value.entity) }
|
16
|
+
else
|
17
|
+
facet.sort_by!{|facet_value| facet_value.entity.send(order_accessor) }
|
18
|
+
end
|
19
|
+
else
|
20
|
+
facet.sort_by!{|facet_value| -facet_value.count }
|
21
|
+
end
|
22
|
+
facet
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
class AssociationFacetFilter < FacetFilter
|
27
|
+
def selected
|
28
|
+
@selected ||= definition.association.klass.find(values)
|
29
|
+
end
|
30
|
+
|
31
|
+
def remove(entity)
|
32
|
+
new_params = search_instance.params || {}
|
33
|
+
old_values = new_params[definition.request_param]
|
34
|
+
old_values.delete(entity.id.to_s)
|
35
|
+
new_params.delete(definition.request_param) if old_values.empty?
|
36
|
+
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
37
|
+
end
|
38
|
+
|
39
|
+
def add(entity)
|
40
|
+
new_params = search_instance.params || {}
|
41
|
+
|
42
|
+
old_values = new_params[definition.request_param] ||= []
|
43
|
+
old_values << entity.id.to_s
|
44
|
+
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
class AttributeFilter < FacetFilter
|
49
|
+
def selected
|
50
|
+
entity = definition.origin_class
|
51
|
+
column = entity.columns_hash[definition.attribute.to_s]
|
52
|
+
values.map{|v| column.type_cast(v)}
|
53
|
+
end
|
54
|
+
|
55
|
+
def build_scope
|
56
|
+
return Proc.new { |base| base } if empty?
|
57
|
+
Proc.new { |base| base.joins(definition.joins).where(definition.qualified_column_name => value) }
|
58
|
+
end
|
59
|
+
|
60
|
+
def facet
|
61
|
+
my_column = definition.qualified_column_name
|
62
|
+
query = "#{my_column} AS facet_value, count(#{my_column}) AS occurrences"
|
63
|
+
counts = without.result.reorder('').joins(definition.joins).select(query).group(my_column)
|
64
|
+
facet = counts.map do |c|
|
65
|
+
is_selected = selected.include?(c.facet_value)
|
66
|
+
FacetValue.new(c.facet_value, c.occurrences, is_selected)
|
67
|
+
end
|
68
|
+
|
69
|
+
order_facet!(facet)
|
70
|
+
end
|
71
|
+
|
72
|
+
def remove(value)
|
73
|
+
new_params = search_instance.params || {}
|
74
|
+
old_values = new_params[definition.request_param]
|
75
|
+
old_values.delete(value.to_s)
|
76
|
+
new_params.delete(definition.request_param) if old_values.empty?
|
77
|
+
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
78
|
+
end
|
79
|
+
|
80
|
+
def add(value)
|
81
|
+
new_params = search_instance.params || {}
|
82
|
+
old_values = new_params[definition.request_param] ||= []
|
83
|
+
old_values << value.to_s
|
84
|
+
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
85
|
+
end
|
86
|
+
|
87
|
+
end
|
88
|
+
|
89
|
+
class BelongsToFilter < AssociationFacetFilter
|
90
|
+
def build_scope
|
91
|
+
return Proc.new { |base| base } if empty?
|
92
|
+
Proc.new { |base| base.joins(definition.joins).where(definition.qualified_column_name => values) }
|
93
|
+
end
|
94
|
+
|
95
|
+
def facet
|
96
|
+
my_column = definition.qualified_column_name
|
97
|
+
query = "#{my_column} AS foreign_id, count(#{my_column}) AS occurrences"
|
98
|
+
counts = without.result.reorder('').joins(definition.joins).select(query).group(my_column)
|
99
|
+
entities_by_id = definition.association.klass.find(counts.map(&:foreign_id)).group_by(&:id)
|
100
|
+
|
101
|
+
facet = counts.map do |count|
|
102
|
+
facet_entity = entities_by_id[count.foreign_id].first
|
103
|
+
is_selected = selected.include?(facet_entity)
|
104
|
+
FacetValue.new(facet_entity, count.occurrences, is_selected)
|
105
|
+
end
|
106
|
+
|
107
|
+
order_facet!(facet)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
class HasManyFilter < AssociationFacetFilter
|
112
|
+
def build_scope
|
113
|
+
return Proc.new { |base| base } if empty?
|
114
|
+
Proc.new do |base|
|
115
|
+
base_table = definition.origin_class.table_name
|
116
|
+
join_name = [definition.association.name.to_s, base_table.to_s].sort.join('_')
|
117
|
+
|
118
|
+
primary_key_column = "#{base_table}.#{definition.origin_class.primary_key}"
|
119
|
+
|
120
|
+
subquery = base.joins(definition.joins).select(primary_key_column)
|
121
|
+
.where("#{join_name}.#{definition.association.foreign_key}" => values).uniq
|
122
|
+
|
123
|
+
base.joins(definition.joins).where(primary_key_column => subquery.select(primary_key_column)).uniq
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def facet
|
128
|
+
base_table = definition.search.root_class.table_name
|
129
|
+
join_name = [definition.association.name.to_s, base_table.to_s].sort.join('_')
|
130
|
+
foreign_id_col = definition.association.name.to_s.singularize + '_id'
|
131
|
+
my_column = join_name + '.' + foreign_id_col
|
132
|
+
counts = without.result
|
133
|
+
.reorder('')
|
134
|
+
.joins(definition.joins)
|
135
|
+
.select("#{my_column} as foreign_id, count(#{my_column}) as occurrences")
|
136
|
+
.group(my_column)
|
137
|
+
entities_by_id = definition.association.klass.find(counts.map(&:foreign_id)).group_by(&:id)
|
138
|
+
|
139
|
+
facet = counts.map do |count|
|
140
|
+
facet_entity = entities_by_id[count.foreign_id].first
|
141
|
+
is_selected = selected.include?(facet_entity)
|
142
|
+
FacetValue.new(facet_entity, count.occurrences, is_selected)
|
143
|
+
end
|
144
|
+
|
145
|
+
order_facet!(facet)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def build_filter(search_instance, param_value)
|
150
|
+
if association
|
151
|
+
if association.macro == :belongs_to
|
152
|
+
BelongsToFilter.new(self, search_instance, param_value)
|
153
|
+
elsif association.macro == :has_many
|
154
|
+
HasManyFilter.new(self, search_instance, param_value)
|
155
|
+
else
|
156
|
+
raise "Unsupported association type: #{association.macro}"
|
157
|
+
end
|
158
|
+
else
|
159
|
+
AttributeFilter.new(self, search_instance, param_value)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
@@ -3,7 +3,11 @@ module FortyFacets
|
|
3
3
|
class RangeFilter < Filter
|
4
4
|
def build_scope
|
5
5
|
return Proc.new { |base| base } if empty?
|
6
|
-
|
6
|
+
|
7
|
+
Proc.new do |base|
|
8
|
+
base.joins(definition.joins)
|
9
|
+
.where("#{definition.qualified_column_name} >= ? AND #{definition.qualified_column_name} <= ? ", min_value, max_value )
|
10
|
+
end
|
7
11
|
end
|
8
12
|
|
9
13
|
def min_value
|
@@ -17,7 +21,7 @@ module FortyFacets
|
|
17
21
|
end
|
18
22
|
|
19
23
|
def absolute_interval
|
20
|
-
@abosultes ||= without.result.reorder('').select("min(#{
|
24
|
+
@abosultes ||= without.result.reorder('').select("min(#{definition.qualified_column_name}) AS min, max(#{definition.qualified_column_name}) as max").first
|
21
25
|
end
|
22
26
|
|
23
27
|
def absolute_min
|
@@ -4,11 +4,12 @@ module FortyFacets
|
|
4
4
|
def build_scope
|
5
5
|
return Proc.new { |base| base } if empty?
|
6
6
|
like_value = expression_value(value)
|
7
|
-
|
7
|
+
operator = definition.options[:ignore_case] ? 'ILIKE' : 'LIKE'
|
8
|
+
Proc.new { |base| base.joins(definition.joins).where("#{definition.qualified_column_name} #{operator} ?", like_value ) }
|
8
9
|
end
|
9
10
|
|
10
11
|
def expression_value(term)
|
11
|
-
if
|
12
|
+
if definition.options[:prefix]
|
12
13
|
"#{term}%"
|
13
14
|
else
|
14
15
|
"%#{term}%"
|
data/lib/forty_facets/filter.rb
CHANGED
@@ -2,9 +2,12 @@ module FortyFacets
|
|
2
2
|
# Base class for the objects representing a specific value for a specific
|
3
3
|
# type of filter. Most FilterDefinitions will have their own Filter subclass
|
4
4
|
# to control values for display and rendering to request parameters.
|
5
|
-
Filter = Struct.new(:
|
5
|
+
Filter = Struct.new(:definition, :search_instance, :value) do
|
6
|
+
|
7
|
+
FacetValue = Struct.new(:entity, :count, :selected)
|
8
|
+
|
6
9
|
def name
|
7
|
-
|
10
|
+
definition.options[:name] || definition.path.join(' ')
|
8
11
|
end
|
9
12
|
|
10
13
|
def empty?
|
@@ -16,33 +19,10 @@ module FortyFacets
|
|
16
19
|
search = search_instance
|
17
20
|
return search if empty?
|
18
21
|
new_params = search_instance.params || {}
|
19
|
-
new_params.delete(
|
22
|
+
new_params.delete(definition.request_param)
|
20
23
|
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
21
24
|
end
|
22
25
|
end
|
23
26
|
|
24
|
-
# Base class for filter with multiple values and grouped facet values
|
25
|
-
class FacetFilter < Filter
|
26
|
-
def values
|
27
|
-
@values ||= Array.wrap(value).sort.uniq
|
28
|
-
end
|
29
|
-
|
30
|
-
protected
|
31
|
-
|
32
|
-
def order_facet!(facet)
|
33
|
-
order_accessor = filter_definition.options[:order]
|
34
|
-
if order_accessor
|
35
|
-
if order_accessor.is_a?(Proc)
|
36
|
-
facet.sort_by!{|facet_value| order_accessor.call(facet_value.entity) }
|
37
|
-
else
|
38
|
-
facet.sort_by!{|facet_value| facet_value.entity.send(order_accessor) }
|
39
|
-
end
|
40
|
-
else
|
41
|
-
facet.sort_by!{|facet_value| -facet_value.count }
|
42
|
-
end
|
43
|
-
facet
|
44
|
-
end
|
45
|
-
|
46
|
-
end
|
47
27
|
end
|
48
28
|
|
@@ -1,11 +1,57 @@
|
|
1
1
|
module FortyFacets
|
2
2
|
# Base class for the classes storing the definition of differently behaving filters
|
3
|
-
FilterDefinition
|
3
|
+
class FilterDefinition
|
4
4
|
|
5
|
-
|
5
|
+
attr(:search, :path, :options, :joins, :table_name, :column_name,
|
6
|
+
:origin_class, :association, :attribute)
|
7
|
+
|
8
|
+
def initialize search, path, options
|
9
|
+
@search = search
|
10
|
+
@path = [path].flatten
|
11
|
+
@options = options
|
12
|
+
|
13
|
+
init_associations
|
14
|
+
end
|
6
15
|
|
7
16
|
def request_param
|
8
|
-
|
17
|
+
path.join('-')
|
18
|
+
end
|
19
|
+
|
20
|
+
def qualified_column_name
|
21
|
+
"#{table_name}.#{column_name}"
|
22
|
+
end
|
23
|
+
|
24
|
+
protected
|
25
|
+
|
26
|
+
# Walk the association path and gather required joins, table names etc.
|
27
|
+
def init_associations
|
28
|
+
current_class = search.root_class
|
29
|
+
current_association = nil
|
30
|
+
|
31
|
+
joins = []
|
32
|
+
|
33
|
+
path.each do |current_attribute|
|
34
|
+
current_association = current_class.reflect_on_association(current_attribute)
|
35
|
+
|
36
|
+
if current_attribute == path.last
|
37
|
+
if current_association
|
38
|
+
joins << current_attribute
|
39
|
+
@column_name = current_association.foreign_key
|
40
|
+
else
|
41
|
+
@column_name = current_attribute.to_s
|
42
|
+
end
|
43
|
+
else
|
44
|
+
joins << current_attribute
|
45
|
+
current_class = current_association.klass
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
@table_name = current_class.table_name
|
50
|
+
@origin_class = current_class
|
51
|
+
@association = current_association
|
52
|
+
@attribute = path.last
|
53
|
+
|
54
|
+
@joins = joins.reverse.drop(1).inject(joins.last) { |a, n| { n => a } }
|
9
55
|
end
|
10
56
|
end
|
11
57
|
end
|
data/lib/forty_facets/version.rb
CHANGED
data/lib/forty_facets.rb
CHANGED
@@ -7,10 +7,7 @@ require "forty_facets/order_definition"
|
|
7
7
|
require "forty_facets/order"
|
8
8
|
require "forty_facets/filter"
|
9
9
|
require "forty_facets/filter_definition"
|
10
|
-
require "forty_facets/filter/belongs_to_filter_definition"
|
11
|
-
require "forty_facets/filter/belongs_to_chain_filter_definition"
|
12
|
-
require "forty_facets/filter/has_many_filter_definition"
|
13
10
|
require "forty_facets/filter/range_filter_definition"
|
14
11
|
require "forty_facets/filter/text_filter_definition"
|
15
|
-
require "forty_facets/filter/
|
12
|
+
require "forty_facets/filter/facet_filter_definition"
|
16
13
|
require "forty_facets/facet_search"
|
data/test/fixtures.rb
CHANGED
@@ -11,6 +11,12 @@ ActiveRecord::Schema.define do
|
|
11
11
|
|
12
12
|
create_table :studios do |t|
|
13
13
|
t.integer :country_id
|
14
|
+
t.string :status
|
15
|
+
t.string :name
|
16
|
+
t.string :description
|
17
|
+
end
|
18
|
+
|
19
|
+
create_table :producers do |t|
|
14
20
|
t.string :name
|
15
21
|
end
|
16
22
|
|
@@ -48,6 +54,14 @@ ActiveRecord::Schema.define do
|
|
48
54
|
t.integer :genre_id
|
49
55
|
end
|
50
56
|
|
57
|
+
create_table :producers_studios do |t|
|
58
|
+
t.integer :producer_id
|
59
|
+
t.integer :studio_id
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
class Producer < ActiveRecord::Base
|
51
65
|
end
|
52
66
|
|
53
67
|
class Actor < ActiveRecord::Base
|
@@ -64,6 +78,7 @@ end
|
|
64
78
|
|
65
79
|
class Studio < ActiveRecord::Base
|
66
80
|
belongs_to :country
|
81
|
+
has_and_belongs_to_many :producers
|
67
82
|
end
|
68
83
|
|
69
84
|
class Movie < ActiveRecord::Base
|
@@ -73,14 +88,32 @@ class Movie < ActiveRecord::Base
|
|
73
88
|
has_and_belongs_to_many :writers
|
74
89
|
end
|
75
90
|
|
91
|
+
LOREM = %w{Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren}
|
92
|
+
|
76
93
|
countries = []
|
77
94
|
%w{US UK}.each do |code|
|
78
95
|
countries << Country.create!(name: code)
|
79
96
|
end
|
80
97
|
|
98
|
+
producers = []
|
99
|
+
%w(Smith Logan Kelly Anderson Hendricks Bush).each do |name|
|
100
|
+
producers << Producer.create!(name: name)
|
101
|
+
end
|
102
|
+
|
103
|
+
|
81
104
|
studios = []
|
82
105
|
%w{A B C D}.each_with_index do |suffix, index|
|
83
|
-
|
106
|
+
studio = Studio.create!(name: "Studio #{suffix}", status: %w(active inactive)[index % 2],
|
107
|
+
country: countries[index % countries.length], description: LOREM.shuffle.take(5).join(' '))
|
108
|
+
|
109
|
+
3.times do
|
110
|
+
producer = producers[rand(producers.length)]
|
111
|
+
unless studio.producers.include? producer
|
112
|
+
studio.producers << producer
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
studios << studio
|
84
117
|
end
|
85
118
|
|
86
119
|
genres = []
|
@@ -99,7 +132,7 @@ writers = []
|
|
99
132
|
end
|
100
133
|
|
101
134
|
rand = Random.new
|
102
|
-
|
135
|
+
LOREM.each_with_index do |title, index|
|
103
136
|
m = Movie.create!(title: title, studio: studios[index % studios.length], price: rand.rand(20.0), year: (index%3 + 2010) )
|
104
137
|
3.times do
|
105
138
|
actor = actors[rand(actors.length)]
|
data/test/smoke_test.rb
CHANGED
@@ -21,6 +21,9 @@ class MovieSearch < FortyFacets::FacetSearch
|
|
21
21
|
range :price, name: 'Price'
|
22
22
|
facet :writers, name: 'Writer'
|
23
23
|
facet [:studio, :country], name: 'Country'
|
24
|
+
facet [:studio, :status], name: 'Studio status'
|
25
|
+
facet [:studio, :producers], name: 'Producers'
|
26
|
+
text [:studio, :description], name: 'Studio Description'
|
24
27
|
end
|
25
28
|
|
26
29
|
class SmokeTest < Minitest::Test
|
@@ -31,19 +34,35 @@ class SmokeTest < Minitest::Test
|
|
31
34
|
end
|
32
35
|
|
33
36
|
def test_text_filter
|
34
|
-
search = MovieSearch.new({'search' => { title
|
37
|
+
search = MovieSearch.new({'search' => { 'title' => 'ipsum' }})
|
35
38
|
assert_equal 1, search.result.size
|
36
39
|
assert_equal 'ipsum', search.result.first.title
|
37
40
|
end
|
38
41
|
|
39
42
|
def test_year_filter
|
40
|
-
search = MovieSearch.new({'search' => { year
|
43
|
+
search = MovieSearch.new({'search' => { 'year' => '2011' }})
|
41
44
|
assert_equal [2011], search.result.map(&:year).uniq
|
42
45
|
|
43
46
|
facet = search.filter(:year).facet
|
44
47
|
assert_equal Movie.count, facet.map(&:count).sum
|
45
48
|
end
|
46
49
|
|
50
|
+
def test_range_filter
|
51
|
+
search = MovieSearch.new({'search' => {'price' => '0 - 20'}})
|
52
|
+
assert_equal Movie.count, search.result.size
|
53
|
+
|
54
|
+
search = MovieSearch.new({'search' => {'price' => '0 - 10'}})
|
55
|
+
assert_equal Movie.all.reject{|m| m.price > 10}.size, search.result.size
|
56
|
+
end
|
57
|
+
|
58
|
+
def test_text_filter_via_belongs_to
|
59
|
+
description = Studio.first.description
|
60
|
+
search = MovieSearch.new({'search' => { 'studio-description' => description }})
|
61
|
+
|
62
|
+
assert_equal Movie.all.reject{|m| m.studio.description != description}.size, search.result.size
|
63
|
+
assert_equal description, search.result.first.studio.description
|
64
|
+
end
|
65
|
+
|
47
66
|
def test_country_filter
|
48
67
|
search = MovieSearch.new('search' => { 'studio-country' => Country.first.id.to_s})
|
49
68
|
assert_equal [Country.first], search.result.map{|m| m.studio.country}.uniq
|
@@ -57,11 +76,21 @@ class SmokeTest < Minitest::Test
|
|
57
76
|
def test_selected_country_filter
|
58
77
|
search = MovieSearch.new('search' => { 'studio-country' => Country.first.id.to_s})
|
59
78
|
filter = search.filter([:studio, :country])
|
79
|
+
assert_equal FortyFacets::FacetFilterDefinition::BelongsToFilter, filter.class
|
60
80
|
assert_equal [Country.first], filter.selected
|
61
81
|
|
62
82
|
assert_equal Movie.count / 2, filter.facet.reject(&:selected).first.count
|
63
83
|
end
|
64
84
|
|
85
|
+
def test_studio_status_filter
|
86
|
+
search = MovieSearch.new('search' => { 'studio-status' => 'active'})
|
87
|
+
assert_equal ['active'], search.result.map{|m| m.studio.status}.uniq
|
88
|
+
assert_equal Movie.count / 2, search.result.count
|
89
|
+
|
90
|
+
filter = search.filter([:studio, :status])
|
91
|
+
assert_equal ['active'], filter.selected
|
92
|
+
end
|
93
|
+
|
65
94
|
def test_year_add_remove_filter
|
66
95
|
|
67
96
|
search = MovieSearch.new()
|
@@ -114,18 +143,29 @@ class SmokeTest < Minitest::Test
|
|
114
143
|
blank_search = MovieSearch.new
|
115
144
|
genre = Genre.first
|
116
145
|
expected = Movie.order(:id).select{|m| m.genres.include?(genre)}
|
117
|
-
assert blank_search.filter(:genres).is_a?(FortyFacets::
|
146
|
+
assert blank_search.filter(:genres).is_a?(FortyFacets::FacetFilterDefinition::HasManyFilter)
|
118
147
|
search = blank_search.filter(:genres).add(genre)
|
119
148
|
actual = search.result
|
120
149
|
|
121
150
|
assert_equal expected.size, actual.size
|
122
151
|
end
|
123
152
|
|
153
|
+
def test_hast_many_via_belongs_to
|
154
|
+
blank_search = MovieSearch.new
|
155
|
+
producer = Producer.first
|
156
|
+
expected = Movie.order(:id).select{|m| m.studio.producers.include? producer}
|
157
|
+
assert blank_search.filter([:studio, :producers]).is_a?(FortyFacets::FacetFilterDefinition::HasManyFilter)
|
158
|
+
search = blank_search.filter([:studio, :producers]).add(producer)
|
159
|
+
actual = search.result
|
160
|
+
|
161
|
+
assert_equal expected.size, actual.size
|
162
|
+
end
|
163
|
+
|
124
164
|
def test_has_many_writers
|
125
165
|
blank_search = MovieSearch.new
|
126
166
|
writer = Writer.first
|
127
167
|
expected = Movie.order(:id).select{|m| m.writers.include?(writer)}
|
128
|
-
assert blank_search.filter(:writers).is_a?(FortyFacets::
|
168
|
+
assert blank_search.filter(:writers).is_a?(FortyFacets::FacetFilterDefinition::HasManyFilter)
|
129
169
|
search = blank_search.filter(:writers).add(writer)
|
130
170
|
actual = search.result
|
131
171
|
|
@@ -139,7 +179,7 @@ class SmokeTest < Minitest::Test
|
|
139
179
|
expected = Movie.order(:id)
|
140
180
|
.select{|m| m.genres.include?(genre)}
|
141
181
|
.select{|m| m.actors.include?(actor)}
|
142
|
-
assert blank_search.filter(:genres).is_a?(FortyFacets::
|
182
|
+
assert blank_search.filter(:genres).is_a?(FortyFacets::FacetFilterDefinition::HasManyFilter)
|
143
183
|
search_with_genre = blank_search.filter(:genres).add(genre)
|
144
184
|
search_with_genre_and_actor = search_with_genre.filter(:actors).add(actor)
|
145
185
|
actual = search_with_genre_and_actor.result
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: forty_facets
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.11
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Axel Tetzlaff
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2014-09-
|
11
|
+
date: 2014-09-25 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -115,10 +115,7 @@ files:
|
|
115
115
|
- lib/forty_facets.rb
|
116
116
|
- lib/forty_facets/facet_search.rb
|
117
117
|
- lib/forty_facets/filter.rb
|
118
|
-
- lib/forty_facets/filter/
|
119
|
-
- lib/forty_facets/filter/belongs_to_chain_filter_definition.rb
|
120
|
-
- lib/forty_facets/filter/belongs_to_filter_definition.rb
|
121
|
-
- lib/forty_facets/filter/has_many_filter_definition.rb
|
118
|
+
- lib/forty_facets/filter/facet_filter_definition.rb
|
122
119
|
- lib/forty_facets/filter/range_filter_definition.rb
|
123
120
|
- lib/forty_facets/filter/text_filter_definition.rb
|
124
121
|
- lib/forty_facets/filter_definition.rb
|
@@ -1,46 +0,0 @@
|
|
1
|
-
module FortyFacets
|
2
|
-
class AttributeFilterDefinition < FilterDefinition
|
3
|
-
class AttributeFilter < FacetFilter
|
4
|
-
def selected
|
5
|
-
entity = search_instance.class.root_class
|
6
|
-
column = entity.columns_hash[filter_definition.model_field.to_s]
|
7
|
-
values.map{|v| column.type_cast(v)}
|
8
|
-
end
|
9
|
-
|
10
|
-
def build_scope
|
11
|
-
return Proc.new { |base| base } if empty?
|
12
|
-
Proc.new { |base| base.where(filter_definition.model_field => value) }
|
13
|
-
end
|
14
|
-
|
15
|
-
def facet
|
16
|
-
my_column = filter_definition.model_field
|
17
|
-
counts = without.result.reorder('').select("#{my_column} AS facet_value, count(#{my_column}) as occurrences").group(my_column)
|
18
|
-
facet = counts.map do |c|
|
19
|
-
is_selected = selected.include?(c.facet_value)
|
20
|
-
FacetValue.new(c.facet_value, c.occurrences, is_selected)
|
21
|
-
end
|
22
|
-
|
23
|
-
order_facet!(facet)
|
24
|
-
end
|
25
|
-
|
26
|
-
def remove(value)
|
27
|
-
new_params = search_instance.params || {}
|
28
|
-
old_values = new_params[filter_definition.request_param]
|
29
|
-
old_values.delete(value.to_s)
|
30
|
-
new_params.delete(filter_definition.request_param) if old_values.empty?
|
31
|
-
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
32
|
-
end
|
33
|
-
|
34
|
-
def add(value)
|
35
|
-
new_params = search_instance.params || {}
|
36
|
-
old_values = new_params[filter_definition.request_param] ||= []
|
37
|
-
old_values << value.to_s
|
38
|
-
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
39
|
-
end
|
40
|
-
end
|
41
|
-
|
42
|
-
def build_filter(search_instance, value)
|
43
|
-
AttributeFilter.new(self, search_instance, value)
|
44
|
-
end
|
45
|
-
end
|
46
|
-
end
|
@@ -1,79 +0,0 @@
|
|
1
|
-
module FortyFacets
|
2
|
-
class BelongsToChainFilterDefinition < FilterDefinition
|
3
|
-
class BelongsToChainFilter < FacetFilter
|
4
|
-
def association
|
5
|
-
current_association = nil
|
6
|
-
current_class = filter_definition.search.root_class
|
7
|
-
|
8
|
-
filter_definition.model_field.each do |field|
|
9
|
-
current_association = current_class.reflect_on_association(field)
|
10
|
-
current_class = current_association.klass
|
11
|
-
end
|
12
|
-
|
13
|
-
current_association
|
14
|
-
end
|
15
|
-
|
16
|
-
# class objects in this filter
|
17
|
-
def klass
|
18
|
-
association.klass
|
19
|
-
end
|
20
|
-
|
21
|
-
def selected
|
22
|
-
@selected ||= klass.find(values)
|
23
|
-
end
|
24
|
-
|
25
|
-
def joins
|
26
|
-
fields = filter_definition.model_field
|
27
|
-
fields.reverse.drop(1).inject(fields.last) { |a, n| { n => a } }
|
28
|
-
end
|
29
|
-
|
30
|
-
def build_scope
|
31
|
-
return Proc.new { |base| base } if empty?
|
32
|
-
|
33
|
-
Proc.new do |base|
|
34
|
-
condition = {association.klass.table_name => {id: values}}
|
35
|
-
base.joins(joins).where(condition)
|
36
|
-
end
|
37
|
-
end
|
38
|
-
|
39
|
-
def facet
|
40
|
-
my_column = association.association_foreign_key
|
41
|
-
counts = without.result.reorder('').joins(joins).select("#{my_column} AS foreign_id, count(#{my_column}) AS occurrences").group(my_column)
|
42
|
-
entities_by_id = klass.find(counts.map(&:foreign_id)).group_by(&:id)
|
43
|
-
|
44
|
-
facet = counts.map do |count|
|
45
|
-
facet_entity = entities_by_id[count.foreign_id].first
|
46
|
-
is_selected = selected.include?(facet_entity)
|
47
|
-
FacetValue.new(facet_entity, count.occurrences, is_selected)
|
48
|
-
end
|
49
|
-
|
50
|
-
order_facet!(facet)
|
51
|
-
end
|
52
|
-
|
53
|
-
def remove(entity)
|
54
|
-
new_params = search_instance.params || {}
|
55
|
-
old_values = new_params[filter_definition.request_param]
|
56
|
-
old_values.delete(entity.id.to_s)
|
57
|
-
new_params.delete(filter_definition.request_param) if old_values.empty?
|
58
|
-
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
59
|
-
end
|
60
|
-
|
61
|
-
def add(entity)
|
62
|
-
new_params = search_instance.params || {}
|
63
|
-
old_values = new_params[filter_definition.request_param] ||= []
|
64
|
-
old_values << entity.id.to_s
|
65
|
-
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
66
|
-
end
|
67
|
-
|
68
|
-
end
|
69
|
-
|
70
|
-
def build_filter(search_instance, param_value)
|
71
|
-
BelongsToChainFilter.new(self, search_instance, param_value)
|
72
|
-
end
|
73
|
-
|
74
|
-
def request_param
|
75
|
-
model_field.join('-')
|
76
|
-
end
|
77
|
-
|
78
|
-
end
|
79
|
-
end
|
@@ -1,58 +0,0 @@
|
|
1
|
-
module FortyFacets
|
2
|
-
class BelongsToFilterDefinition < FilterDefinition
|
3
|
-
class BelongsToFilter < FacetFilter
|
4
|
-
def association
|
5
|
-
filter_definition.search.root_class.reflect_on_association(filter_definition.model_field)
|
6
|
-
end
|
7
|
-
|
8
|
-
# class objects in this filter
|
9
|
-
def klass
|
10
|
-
association.klass
|
11
|
-
end
|
12
|
-
|
13
|
-
def selected
|
14
|
-
@selected ||= klass.find(values)
|
15
|
-
end
|
16
|
-
|
17
|
-
def build_scope
|
18
|
-
return Proc.new { |base| base } if empty?
|
19
|
-
Proc.new { |base| base.where(association.association_foreign_key => values) }
|
20
|
-
end
|
21
|
-
|
22
|
-
def facet
|
23
|
-
my_column = association.association_foreign_key
|
24
|
-
counts = without.result.reorder('').select("#{my_column} as foreign_id, count(#{my_column}) as occurrences").group(my_column)
|
25
|
-
entities_by_id = klass.find(counts.map(&:foreign_id)).group_by(&:id)
|
26
|
-
|
27
|
-
facet = counts.map do |count|
|
28
|
-
facet_entity = entities_by_id[count.foreign_id].first
|
29
|
-
is_selected = selected.include?(facet_entity)
|
30
|
-
FacetValue.new(facet_entity, count.occurrences, is_selected)
|
31
|
-
end
|
32
|
-
|
33
|
-
order_facet!(facet)
|
34
|
-
end
|
35
|
-
|
36
|
-
def remove(entity)
|
37
|
-
new_params = search_instance.params || {}
|
38
|
-
old_values = new_params[filter_definition.request_param]
|
39
|
-
old_values.delete(entity.id.to_s)
|
40
|
-
new_params.delete(filter_definition.request_param) if old_values.empty?
|
41
|
-
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
42
|
-
end
|
43
|
-
|
44
|
-
def add(entity)
|
45
|
-
new_params = search_instance.params || {}
|
46
|
-
old_values = new_params[filter_definition.request_param] ||= []
|
47
|
-
old_values << entity.id.to_s
|
48
|
-
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
49
|
-
end
|
50
|
-
|
51
|
-
end
|
52
|
-
|
53
|
-
def build_filter(search_instance, param_value)
|
54
|
-
BelongsToFilter.new(self, search_instance, param_value)
|
55
|
-
end
|
56
|
-
|
57
|
-
end
|
58
|
-
end
|
@@ -1,74 +0,0 @@
|
|
1
|
-
module FortyFacets
|
2
|
-
class HasManyFilterDefinition < FilterDefinition
|
3
|
-
class HasManyFilter < FacetFilter
|
4
|
-
def association
|
5
|
-
filter_definition.search.root_class.reflect_on_association(filter_definition.model_field)
|
6
|
-
end
|
7
|
-
|
8
|
-
# class objects in this filter
|
9
|
-
def klass
|
10
|
-
association.klass
|
11
|
-
end
|
12
|
-
|
13
|
-
def selected
|
14
|
-
@selected ||= klass.find(values)
|
15
|
-
end
|
16
|
-
|
17
|
-
def build_scope
|
18
|
-
return Proc.new { |base| base } if empty?
|
19
|
-
Proc.new do |base|
|
20
|
-
base_table = filter_definition.search.root_class.table_name
|
21
|
-
join_name = [association.name.to_s, base_table.to_s].sort.join('_')
|
22
|
-
foreign_id_col = association.name.to_s.singularize + '_id'
|
23
|
-
# this will actually generate a subquery
|
24
|
-
base.where(id: base.joins(association.options[:through])
|
25
|
-
.where(join_name + '.' + foreign_id_col => values)
|
26
|
-
.group(base_table + '.id').select(base_table + '.id'))
|
27
|
-
end
|
28
|
-
end
|
29
|
-
|
30
|
-
def facet
|
31
|
-
base_table = filter_definition.search.root_class.table_name
|
32
|
-
join_name = [association.name.to_s, base_table.to_s].sort.join('_')
|
33
|
-
foreign_id_col = association.name.to_s.singularize + '_id'
|
34
|
-
my_column = join_name + '.' + foreign_id_col
|
35
|
-
counts = without.result
|
36
|
-
.reorder('')
|
37
|
-
.joins(association.options[:through])
|
38
|
-
.select("#{my_column} as foreign_id, count(#{my_column}) as occurrences")
|
39
|
-
.group(my_column)
|
40
|
-
entities_by_id = klass.find(counts.map(&:foreign_id)).group_by(&:id)
|
41
|
-
|
42
|
-
facet = counts.map do |count|
|
43
|
-
facet_entity = entities_by_id[count.foreign_id].first
|
44
|
-
is_selected = selected.include?(facet_entity)
|
45
|
-
FacetValue.new(facet_entity, count.occurrences, is_selected)
|
46
|
-
end
|
47
|
-
|
48
|
-
order_facet!(facet)
|
49
|
-
end
|
50
|
-
|
51
|
-
def remove(entity)
|
52
|
-
new_params = search_instance.params || {}
|
53
|
-
old_values = new_params[filter_definition.request_param]
|
54
|
-
old_values.delete(entity.id.to_s)
|
55
|
-
new_params.delete(filter_definition.request_param) if old_values.empty?
|
56
|
-
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
57
|
-
end
|
58
|
-
|
59
|
-
def add(entity)
|
60
|
-
new_params = search_instance.params || {}
|
61
|
-
old_values = new_params[filter_definition.request_param] ||= []
|
62
|
-
old_values << entity.id.to_s
|
63
|
-
search_instance.class.new_unwrapped(new_params, search_instance.root)
|
64
|
-
end
|
65
|
-
|
66
|
-
end
|
67
|
-
|
68
|
-
def build_filter(search_instance, param_value)
|
69
|
-
HasManyFilter.new(self, search_instance, param_value)
|
70
|
-
end
|
71
|
-
|
72
|
-
end
|
73
|
-
end
|
74
|
-
|