attr_searchable 0.0.1

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,159 @@
1
+
2
+ module AttrSearchable
3
+ module Arel
4
+ module Visitors
5
+ module ToSql
6
+ if ::Arel::VERSION >= "4.0.1"
7
+ def visit_AttrSearchableGrammar_Nodes_And(o, a)
8
+ visit ::Arel::Nodes::Grouping.new(o.to_arel), a
9
+ end
10
+
11
+ def visit_AttrSearchableGrammar_Nodes_Or(o, a)
12
+ visit ::Arel::Nodes::Grouping.new(o.to_arel), a
13
+ end
14
+ else
15
+ def visit_AttrSearchableGrammar_Nodes_And(o)
16
+ visit ::Arel::Nodes::Grouping.new(o.to_arel)
17
+ end
18
+
19
+ def visit_AttrSearchableGrammar_Nodes_Or(o)
20
+ visit ::Arel::Nodes::Grouping.new(o.to_arel)
21
+ end
22
+ end
23
+ end
24
+
25
+ module MySQL
26
+ if ::Arel::VERSION >= "4.0.1"
27
+ def visit_AttrSearchableGrammar_Attributes_Collection(o, a)
28
+ o.attributes.collect { |attribute| visit attribute.attribute, a }.join(", ")
29
+ end
30
+
31
+ def visit_AttrSearchableGrammar_Nodes_FulltextExpression(o, a)
32
+ "MATCH(#{visit o.collection, a}) AGAINST(#{visit visit(o.node, a), a} IN BOOLEAN MODE)"
33
+ end
34
+
35
+ def visit_AttrSearchableGrammar_Nodes_MatchesFulltextNot(o, a)
36
+ o.right.split(/[\s+'"<>()~-]+/).collect { |word| "-#{word}" }.join(" ")
37
+ end
38
+
39
+ def visit_AttrSearchableGrammar_Nodes_MatchesFulltext(o, a)
40
+ words = o.right.split(/[\s+'"<>()~-]+/)
41
+
42
+ words.size > 1 ? "\"#{words.join " "}\"" : words.first
43
+ end
44
+
45
+ def visit_AttrSearchableGrammar_Nodes_And_Fulltext(o, a)
46
+ res = o.nodes.collect do |node|
47
+ if node.is_a?(AttrSearchableGrammar::Nodes::MatchesFulltextNot)
48
+ visit node, a
49
+ else
50
+ node.nodes.size > 1 ? "+(#{visit node, a})" : "+#{visit node, a}"
51
+ end
52
+ end
53
+
54
+ res.join " "
55
+ end
56
+
57
+ def visit_AttrSearchableGrammar_Nodes_Or_Fulltext(o, a)
58
+ o.nodes.collect { |node| "(#{visit node, a})" }.join(" ")
59
+ end
60
+ else
61
+ def visit_AttrSearchableGrammar_Attributes_Collection(o)
62
+ o.attributes.collect { |attribute| visit attribute.attribute }.join(", ")
63
+ end
64
+
65
+ def visit_AttrSearchableGrammar_Nodes_FulltextExpression(o)
66
+ "MATCH(#{visit o.collection}) AGAINST(#{visit visit(o.node)} IN BOOLEAN MODE)"
67
+ end
68
+
69
+ def visit_AttrSearchableGrammar_Nodes_MatchesFulltextNot(o)
70
+ o.right.split(/[\s+'"<>()~-]+/).collect { |word| "-#{word}" }.join(" ")
71
+ end
72
+
73
+ def visit_AttrSearchableGrammar_Nodes_MatchesFulltext(o)
74
+ words = o.right.split(/[\s+'"<>()~-]+/)
75
+
76
+ words.size > 1 ? "\"#{words.join " "}\"" : words.first
77
+ end
78
+
79
+ def visit_AttrSearchableGrammar_Nodes_And_Fulltext(o)
80
+ res = o.nodes.collect do |node|
81
+ if node.is_a?(AttrSearchableGrammar::Nodes::MatchesFulltextNot)
82
+ visit node
83
+ else
84
+ node.nodes.size > 1 ? "+(#{visit node})" : "+#{visit node}"
85
+ end
86
+ end
87
+
88
+ res.join " "
89
+ end
90
+
91
+ def visit_AttrSearchableGrammar_Nodes_Or_Fulltext(o)
92
+ o.nodes.collect { |node| "(#{visit node})" }.join(" ")
93
+ end
94
+ end
95
+ end
96
+
97
+ module PostgreSQL
98
+ if ::Arel::VERSION >= "4.0.1"
99
+ def visit_AttrSearchableGrammar_Attributes_Collection(o, a)
100
+ o.attributes.collect { |attribute| visit attribute.attribute, a }.join(" || ' ' || ")
101
+ end
102
+
103
+ def visit_AttrSearchableGrammar_Nodes_FulltextExpression(o, a)
104
+ dictionary = o.collection.options[:dictionary] || "simple"
105
+
106
+ "to_tsvector(#{visit dictionary, a}, #{visit o.collection, a}) @@ to_tsquery(#{visit dictionary, a}, #{visit visit(o.node, a), a})"
107
+ end
108
+
109
+ def visit_AttrSearchableGrammar_Nodes_MatchesFulltextNot(o, a)
110
+ "!'#{o.right}'"
111
+ end
112
+
113
+ def visit_AttrSearchableGrammar_Nodes_MatchesFulltext(o, a)
114
+ "'#{o.right.gsub /[\s&|!:'"]+/, " "}'"
115
+ end
116
+
117
+ def visit_AttrSearchableGrammar_Nodes_And_Fulltext(o, a)
118
+ o.nodes.collect { |node| "(#{visit node, a})" }.join(" & ")
119
+ end
120
+
121
+ def visit_AttrSearchableGrammar_Nodes_Or_Fulltext(o, a)
122
+ o.nodes.collect { |node| "(#{visit node, a})" }.join(" | ")
123
+ end
124
+ else
125
+ def visit_AttrSearchableGrammar_Attributes_Collection(o)
126
+ o.attributes.collect { |attribute| visit attribute.attribute }.join(" || ' ' || ")
127
+ end
128
+
129
+ def visit_AttrSearchableGrammar_Nodes_FulltextExpression(o)
130
+ dictionary = o.collection.options[:dictionary] || "simple"
131
+
132
+ "to_tsvector(#{visit dictionary.to_sym}, #{visit o.collection}) @@ to_tsquery(#{visit dictionary.to_sym}, #{visit visit(o.node)})" # to_sym fixes a 3.2 + postgres bug
133
+ end
134
+
135
+ def visit_AttrSearchableGrammar_Nodes_MatchesFulltextNot(o)
136
+ "!'#{o.right}'"
137
+ end
138
+
139
+ def visit_AttrSearchableGrammar_Nodes_MatchesFulltext(o)
140
+ "'#{o.right.gsub /[\s&|!:'"]+/, " "}'"
141
+ end
142
+
143
+ def visit_AttrSearchableGrammar_Nodes_And_Fulltext(o)
144
+ o.nodes.collect { |node| "(#{visit node})" }.join(" & ")
145
+ end
146
+
147
+ def visit_AttrSearchableGrammar_Nodes_Or_Fulltext(o)
148
+ o.nodes.collect { |node| "(#{visit node})" }.join(" | ")
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+ Arel::Visitors::PostgreSQL.send :include, AttrSearchable::Arel::Visitors::PostgreSQL
157
+ Arel::Visitors::MySQL.send :include, AttrSearchable::Arel::Visitors::MySQL
158
+ Arel::Visitors::ToSql.send :include, AttrSearchable::Arel::Visitors::ToSql
159
+
@@ -0,0 +1,4 @@
1
+
2
+ require "arel"
3
+ require "attr_searchable/arel/visitors"
4
+
@@ -0,0 +1,40 @@
1
+
2
+ class AttrSearchable::HashParser
3
+ def initialize(model)
4
+ @model = model
5
+ end
6
+
7
+ def parse(hash)
8
+ res = hash.collect do |key, value|
9
+ case key
10
+ when :and
11
+ value.collect { |val| parse val }.inject(:and)
12
+ when :or
13
+ value.collect { |val| parse val }.inject(:or)
14
+ when :not
15
+ parse(value).not
16
+ when :query
17
+ AttrSearchable::Parser.parse value, @model
18
+ else
19
+ parse_attribute key, value
20
+ end
21
+ end
22
+
23
+ res.inject :and
24
+ end
25
+
26
+ private
27
+
28
+ def parse_attribute(key, value)
29
+ collection = AttrSearchableGrammar::Attributes::Collection.new(@model, key.to_s)
30
+
31
+ if value.is_a?(Hash)
32
+ raise AttrSearchable::ParseError unless [:matches, :eq, :not_eq, :gt, :gteq, :lt, :lteq].include?(value.keys.first)
33
+
34
+ collection.send value.keys.first, value.values.first.to_s
35
+ else
36
+ collection.send :matches, value.to_s
37
+ end
38
+ end
39
+ end
40
+
@@ -0,0 +1,3 @@
1
+ module AttrSearchable
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,76 @@
1
+
2
+ require "attr_searchable/version"
3
+ require "attr_searchable/arel"
4
+ require "attr_searchable_grammar"
5
+ require "attr_searchable/hash_parser"
6
+ require "treetop"
7
+
8
+ Treetop.load File.expand_path("../attr_searchable_grammar.treetop", __FILE__)
9
+
10
+ module AttrSearchable
11
+ class Error < StandardError; end
12
+ class UnknownColumn < Error; end
13
+ class NoSearchableAttributes < Error; end
14
+ class IncompatibleDatatype < Error; end
15
+ class ParseError < Error; end
16
+
17
+ module Parser
18
+ def self.parse(arg, model)
19
+ arg.is_a?(Hash) ? parse_hash(arg, model) : parse_string(arg, model)
20
+ end
21
+
22
+ def self.parse_hash(hash, model)
23
+ AttrSearchable::HashParser.new(model).parse(hash) || raise(ParseError)
24
+ end
25
+
26
+ def self.parse_string(string, model)
27
+ node = AttrSearchableGrammarParser.new.parse(string) || raise(ParseError)
28
+ node.model = model
29
+ node.to_arel
30
+ end
31
+ end
32
+
33
+ def self.included(base)
34
+ base.class_attribute :searchable_attributes
35
+ base.searchable_attributes = {}
36
+
37
+ base.class_attribute :searchable_attribute_options
38
+ base.searchable_attribute_options = {}
39
+
40
+ base.extend ClassMethods
41
+ end
42
+
43
+ module ClassMethods
44
+ def attr_searchable(*args)
45
+ args.each do |arg|
46
+ attr_searchable_hash arg.is_a?(Hash) ? arg : { arg => arg }
47
+ end
48
+ end
49
+
50
+ def attr_searchable_hash(hash)
51
+ hash.each do |key, value|
52
+ self.searchable_attributes[key.to_s] = Array(value).collect do |column|
53
+ table, attribute = column.to_s =~ /\./ ? column.to_s.split(".") : [name, column]
54
+
55
+ "#{table.tableize}.#{attribute}"
56
+ end
57
+ end
58
+ end
59
+
60
+ def attr_searchable_options(key, options = {})
61
+ self.searchable_attribute_options[key.to_s] = (self.searchable_attribute_options[key.to_s] || {}).merge(options)
62
+ end
63
+
64
+ def search(arg)
65
+ return respond_to?(:scoped) ? scoped : all if arg.blank?
66
+
67
+ scope = respond_to?(:search_scope) ? search_scope : nil
68
+ scope ||= eager_load(searchable_attributes.values.flatten.uniq.collect { |column| column.split(".").first.to_sym } - [name.tableize.to_sym])
69
+
70
+ scope.where AttrSearchable::Parser.parse(arg, self).optimize!
71
+ rescue AttrSearchable::Error
72
+ respond_to?(:none) ? none : where("1 = 0")
73
+ end
74
+ end
75
+ end
76
+
@@ -0,0 +1,196 @@
1
+
2
+ require "treetop"
3
+
4
+ module AttrSearchableGrammar
5
+ module Attributes
6
+ class Collection
7
+ attr_reader :model, :key
8
+
9
+ def initialize(model, key)
10
+ raise AttrSearchable::IncompatibleDatatype unless model.searchable_attributes[key]
11
+
12
+ @model = model
13
+ @key = key
14
+ end
15
+
16
+ def eql?(other)
17
+ self == other
18
+ end
19
+
20
+ def ==(other)
21
+ other.is_a?(self.class) && [model, key] == [other.model, other.key]
22
+ end
23
+
24
+ def hash
25
+ [model, key].hash
26
+ end
27
+
28
+ [:eq, :not_eq, :lt, :lteq, :gt, :gteq].each do |method|
29
+ define_method method do |value|
30
+ attributes.collect! { |attribute| attribute.send method, value }.inject(:or)
31
+ end
32
+ end
33
+
34
+ def matches(value)
35
+ if fulltext?
36
+ AttrSearchableGrammar::Nodes::MatchesFulltext.new self, value.to_s
37
+ else
38
+ attributes.collect! { |attribute| attribute.matches value }.inject(:or)
39
+ end
40
+ end
41
+
42
+ def fulltext?
43
+ (model.searchable_attribute_options[key] || {})[:type] == :fulltext
44
+ end
45
+
46
+ def compatible?(value)
47
+ attributes.all? { |attribute| attribute.compatible? value }
48
+ end
49
+
50
+ def options
51
+ model.searchable_attribute_options[key]
52
+ end
53
+
54
+ def attributes
55
+ @attributes ||= model.searchable_attributes[key].collect do |attribute|
56
+ table, column = attribute.split(".")
57
+ klass = table.classify.constantize
58
+
59
+ Attributes.const_get(klass.columns_hash[column].type.to_s.classify).new(klass.arel_table.alias(klass.table_name)[column], klass, options)
60
+ end
61
+ end
62
+ end
63
+
64
+ class Base
65
+ attr_reader :attribute, :options
66
+
67
+ def initialize(attribute, klass, options = {})
68
+ @attribute = attribute
69
+ @klass = klass
70
+ @options = (options || {})
71
+ end
72
+
73
+ def map(value)
74
+ value
75
+ end
76
+
77
+ def compatible?(value)
78
+ map value
79
+
80
+ true
81
+ rescue AttrSearchable::IncompatibleDatatype
82
+ false
83
+ end
84
+
85
+ def fulltext?
86
+ false
87
+ end
88
+
89
+ { :eq => "Equality", :not_eq => "NotEqual", :lt => "LessThan", :lteq => "LessThanOrEqual", :gt => "GreaterThan", :gteq => "GreaterThanOrEqual", :matches => "Matches" }.each do |method, class_name|
90
+ define_method method do |value|
91
+ raise AttrSearchable::IncompatibleDatatype unless compatible?(value)
92
+
93
+ AttrSearchableGrammar::Nodes.const_get(class_name).new(@attribute, map(value))
94
+ end
95
+ end
96
+
97
+ def method_missing(name, *args, &block)
98
+ @attribute.send name, *args, &block
99
+ end
100
+
101
+ def respond_to?(*args)
102
+ @attribute.respond_to? *args
103
+ end
104
+ end
105
+
106
+ class String < Base
107
+ def matches_value(value)
108
+ return value.gsub(/\*/, "%") if (options[:left_wildcard] != false && value.strip =~ /^[^*]+\*$|^\*[^*]+$/) || value.strip =~ /^[^*]+\*$/
109
+
110
+ options[:left_wildcard] != false ? "%#{value}%" : "#{value}%"
111
+ end
112
+
113
+ def matches(value)
114
+ super matches_value(value)
115
+ end
116
+ end
117
+
118
+ class Text < String; end
119
+
120
+ class WithoutMatches < Base
121
+ def matches(value)
122
+ eq value
123
+ end
124
+ end
125
+
126
+ class Float < WithoutMatches
127
+ def compatible?(value)
128
+ return true if value.to_s =~ /^[0-9]+(\.[0-9]+)?$/
129
+
130
+ false
131
+ end
132
+ end
133
+
134
+ class Integer < Float; end
135
+ class Decimal < Float; end
136
+
137
+ class Datetime < WithoutMatches
138
+ def parse(value)
139
+ return value .. value unless value.is_a?(::String)
140
+
141
+ if value =~ /^[0-9]{4,}$/
142
+ ::Time.new(value).beginning_of_year .. ::Time.new(value).end_of_year
143
+ elsif value =~ /^([0-9]{4,})(\.|-|\/)([0-9]{1,2})$/
144
+ ::Time.new($1, $3, 15).beginning_of_month .. ::Time.new($1, $3, 15).end_of_month
145
+ elsif value =~ /^([0-9]{1,2})(\.|-|\/)([0-9]{4,})$/
146
+ ::Time.new($3, $1, 15).beginning_of_month .. ::Time.new($3, $1, 15).end_of_month
147
+ elsif value !~ /:/
148
+ time = ::Time.parse(value)
149
+ time.beginning_of_day .. time.end_of_day
150
+ else
151
+ time = ::Time.parse(value)
152
+ time .. time
153
+ end
154
+ rescue ArgumentError
155
+ raise AttrSearchable::IncompatibleDatatype
156
+ end
157
+
158
+ def map(value)
159
+ parse(value).first
160
+ end
161
+
162
+ def eq(value)
163
+ between parse(value)
164
+ end
165
+
166
+ def not_eq(value)
167
+ between(parse(value)).not
168
+ end
169
+
170
+ def between(range)
171
+ gteq(range.first).and(lteq(range.last))
172
+ end
173
+ end
174
+
175
+ class Timestamp < Datetime; end
176
+
177
+ class Date < Datetime
178
+ def parse(value)
179
+ dates = super(value).collect { |time| ::Time.parse(time).to_date }
180
+ dates.first .. dates.last
181
+ end
182
+ end
183
+
184
+ class Time < Datetime; end
185
+
186
+ class Boolean < WithoutMatches
187
+ def map(value)
188
+ return true if value.to_s =~ /^(1|true|yes)$/i
189
+ return false if value.to_s =~ /^(0|false|no)$/i
190
+
191
+ raise AttrSearchable::IncompatibleDatatype
192
+ end
193
+ end
194
+ end
195
+ end
196
+
@@ -0,0 +1,167 @@
1
+
2
+ require "treetop"
3
+
4
+ module AttrSearchableGrammar
5
+ module Nodes
6
+ module Base
7
+ def and(node)
8
+ And.new self, node
9
+ end
10
+
11
+ def or(node)
12
+ Or.new self, node
13
+ end
14
+
15
+ def not
16
+ Not.new self
17
+ end
18
+
19
+ def can_flatten?
20
+ false
21
+ end
22
+
23
+ def flatten!
24
+ self
25
+ end
26
+
27
+ def can_group?
28
+ false
29
+ end
30
+
31
+ def group!
32
+ self
33
+ end
34
+
35
+ def fulltext?
36
+ false
37
+ end
38
+
39
+ def can_optimize?
40
+ can_flatten? || can_group?
41
+ end
42
+
43
+ def optimize!
44
+ flatten!.group! while can_optimize?
45
+
46
+ finalize!
47
+ end
48
+
49
+ def finalize!
50
+ self
51
+ end
52
+
53
+ def nodes
54
+ []
55
+ end
56
+ end
57
+
58
+ ["Equality", "NotEqual", "GreaterThan", "LessThan", "GreaterThanOrEqual", "LessThanOrEqual", "Matches", "Not"].each do |name|
59
+ const_set name, Class.new(Arel::Nodes.const_get(name))
60
+ const_get(name).send :include, Base
61
+ end
62
+
63
+ class MatchesFulltext < Arel::Nodes::Binary
64
+ include Base
65
+
66
+ def not
67
+ MatchesFulltextNot.new left, right
68
+ end
69
+
70
+ def fulltext?
71
+ true
72
+ end
73
+
74
+ def finalize!
75
+ FulltextExpression.new collection, self
76
+ end
77
+
78
+ def collection
79
+ left
80
+ end
81
+ end
82
+
83
+ class MatchesFulltextNot < MatchesFulltext; end
84
+
85
+ class FulltextExpression < Arel::Nodes::Node
86
+ include Base
87
+
88
+ attr_reader :collection, :node
89
+
90
+ def initialize(collection, node)
91
+ @collection = collection
92
+ @node = node
93
+ end
94
+ end
95
+
96
+ class Collection < Arel::Nodes::Node
97
+ include Base
98
+
99
+ attr_reader :nodes
100
+
101
+ def initialize(*nodes)
102
+ @nodes = nodes.flatten
103
+ end
104
+
105
+ def can_flatten?
106
+ nodes.any?(&:can_flatten?) || nodes.any? { |node| node.is_a?(self.class) || node.nodes.size == 1 }
107
+ end
108
+
109
+ def flatten!(&block)
110
+ @nodes = nodes.collect(&:flatten!).collect { |node| node.is_a?(self.class) || node.nodes.size == 1 ? node.nodes : node }.flatten
111
+
112
+ self
113
+ end
114
+
115
+ def can_group?
116
+ nodes.reject(&:fulltext?).any?(&:can_group?) || nodes.select(&:fulltext?).group_by(&:collection).any? { |_, group| group.size > 1 }
117
+ end
118
+
119
+ def group!
120
+ @nodes = nodes.reject(&:fulltext?).collect(&:group!) + nodes.select(&:fulltext?).group_by(&:collection).collect { |collection, group| group.size > 1 ? self.class::Fulltext.new(collection, group) : group.first }
121
+
122
+ self
123
+ end
124
+
125
+ def finalize!
126
+ @nodes = nodes.collect(&:finalize!)
127
+
128
+ self
129
+ end
130
+ end
131
+
132
+ class FulltextCollection < Collection
133
+ attr_reader :collection
134
+
135
+ def initialize(collection, *nodes)
136
+ @collection = collection
137
+
138
+ super *nodes
139
+ end
140
+
141
+ def fulltext?
142
+ true
143
+ end
144
+
145
+ def finalize!
146
+ FulltextExpression.new collection, self
147
+ end
148
+ end
149
+
150
+ class And < Collection
151
+ class Fulltext < FulltextCollection; end
152
+
153
+ def to_arel
154
+ nodes.inject { |res, cur| Arel::Nodes::And.new [res, cur] }
155
+ end
156
+ end
157
+
158
+ class Or < Collection
159
+ class Fulltext < FulltextCollection; end
160
+
161
+ def to_arel
162
+ nodes.inject { |res, cur| Arel::Nodes::Or.new res, cur }
163
+ end
164
+ end
165
+ end
166
+ end
167
+