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.
- data/.gitignore +18 -0
- data/.travis.yml +34 -0
- data/Appraisals +14 -0
- data/Gemfile +23 -0
- data/LICENSE.txt +22 -0
- data/README.md +339 -0
- data/Rakefile +9 -0
- data/attr_searchable.gemspec +29 -0
- data/gemfiles/3.2.gemfile +26 -0
- data/gemfiles/4.0.gemfile +26 -0
- data/gemfiles/4.1.gemfile +26 -0
- data/lib/attr_searchable/arel/visitors.rb +159 -0
- data/lib/attr_searchable/arel.rb +4 -0
- data/lib/attr_searchable/hash_parser.rb +40 -0
- data/lib/attr_searchable/version.rb +3 -0
- data/lib/attr_searchable.rb +76 -0
- data/lib/attr_searchable_grammar/attributes.rb +196 -0
- data/lib/attr_searchable_grammar/nodes.rb +167 -0
- data/lib/attr_searchable_grammar.rb +136 -0
- data/lib/attr_searchable_grammar.treetop +55 -0
- data/test/and_test.rb +27 -0
- data/test/attr_searchable_test.rb +44 -0
- data/test/boolean_test.rb +47 -0
- data/test/database.yml +17 -0
- data/test/datetime_test.rb +70 -0
- data/test/float_test.rb +61 -0
- data/test/fulltext_test.rb +27 -0
- data/test/integer_test.rb +61 -0
- data/test/not_test.rb +27 -0
- data/test/or_test.rb +29 -0
- data/test/string_test.rb +84 -0
- data/test/sub_query_test.rb +17 -0
- data/test/test_helper.rb +97 -0
- metadata +204 -0
@@ -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,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,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
|
+
|