searchgasm 0.9.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.
@@ -0,0 +1,142 @@
1
+ module BinaryLogic
2
+ module Searchgasm
3
+ module Search
4
+ class Base
5
+ include Utilities
6
+
7
+ SPECIAL_FIND_OPTIONS = [:order_by, :order_as, :page, :per_page]
8
+ VALID_FIND_OPTIONS = ::ActiveRecord::Base.valid_find_options + SPECIAL_FIND_OPTIONS
9
+
10
+ attr_accessor :klass
11
+ attr_reader :conditions
12
+ attr_writer :options
13
+
14
+ def initialize(klass, options = {})
15
+ self.klass = klass
16
+ self.conditions = Conditions.new(klass)
17
+ self.options = options
18
+ end
19
+
20
+ (::ActiveRecord::Base.valid_find_options - [:conditions]).each do |option|
21
+ class_eval <<-SRC
22
+ def #{option}(sanitize = false); options[:#{option}]; end
23
+ def #{option}=(value); self.options[:#{option}] = value; end
24
+ SRC
25
+ end
26
+
27
+ alias_method :per_page, :limit
28
+
29
+ def all
30
+ klass.all(sanitize)
31
+ end
32
+ alias_method :search, :all
33
+
34
+ def conditions=(value)
35
+ case value
36
+ when Conditions
37
+ @conditions = value
38
+ else
39
+ @conditions.value = value
40
+ end
41
+ end
42
+
43
+ def conditions(sanitize = false)
44
+ sanitize ? @conditions.sanitize : @conditions
45
+ end
46
+
47
+ def find(target)
48
+ case target
49
+ when :all then all
50
+ when :first then first
51
+ else raise(ArgumentError, "The argument must be :all or :first")
52
+ end
53
+ end
54
+
55
+ def first
56
+ klass.first(sanitize)
57
+ end
58
+
59
+ def include(sanitize = false)
60
+ includes = [self.options[:include], conditions.includes].flatten.compact
61
+ includes.blank? ? nil : (includes.size == 1 ? includes.first : includes)
62
+ end
63
+
64
+ def limit=(value)
65
+ return options[:limit] = nil if value.nil? || value == 0
66
+
67
+ old_limit = options[:limit]
68
+ options[:limit] = value
69
+ self.page = @page if !@page.blank? # retry page now that limit is set
70
+ value
71
+ end
72
+ alias_method :per_page=, :limit=
73
+
74
+ def options
75
+ @options ||= {}
76
+ end
77
+
78
+ def options=(options)
79
+ return unless options.is_a?(Hash)
80
+ options.each { |option, value| send("#{option}=", value) }
81
+ end
82
+
83
+ def order_as
84
+ return "DESC" if order.blank?
85
+ order =~ /ASC$/i ? "ASC" : "DESC"
86
+ end
87
+
88
+ def order_as=(value)
89
+ # reset order
90
+ end
91
+
92
+ def order_by
93
+ # need to return a cached value of order_by, not smart to figure it out from order
94
+ end
95
+
96
+ def order_by=(value)
97
+ # do your magic here and set order approperiately
98
+ end
99
+
100
+ def page
101
+ return 1 if offset.blank?
102
+ (offset.to_f / limit).ceil
103
+ end
104
+
105
+ def page=(value)
106
+ return self.offset = nil if value.nil?
107
+
108
+ if limit.blank?
109
+ @page = value
110
+ else
111
+ @page = nil
112
+ self.offset = value * limit
113
+ end
114
+ value
115
+ end
116
+
117
+ def reset!
118
+ conditions.reset!
119
+ self.options = {}
120
+ end
121
+
122
+ def sanitize
123
+ find_options = {}
124
+ ::ActiveRecord::Base.valid_find_options.each do |find_option|
125
+ value = send(find_option, true)
126
+ next if value.blank?
127
+ find_options[find_option] = value
128
+ end
129
+ find_options
130
+ end
131
+
132
+ def scope
133
+ conditions.scope
134
+ end
135
+
136
+ def scope=(value)
137
+ conditions.scope = value
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,168 @@
1
+ module BinaryLogic
2
+ module Searchgasm
3
+ module Search
4
+ class Condition
5
+ include Utilities
6
+
7
+ BLACKLISTED_WORDS = ('a'..'z').to_a + ["about", "an", "are", "as", "at", "be", "by", "com", "de", "en", "for", "from", "how", "in", "is", "it", "la", "of", "on", "or", "that", "the", "the", "this", "to", "und", "was", "what", "when", "where", "who", "will", "with", "www"] # from ranks.nl
8
+ attr_accessor :column, :condition, :name, :klass
9
+ attr_reader :value
10
+
11
+ class << self
12
+ def generate_name(column, condition)
13
+ name_parts = []
14
+ name_parts << (column.is_a?(String) ? column : column.name) unless column.blank?
15
+ name_parts << condition unless condition.blank?
16
+ name_parts.join("_")
17
+ end
18
+ end
19
+
20
+ def initialize(condition, klass, column = nil)
21
+ raise(ArgumentError, "#{klass.name} must acts_as_tree to use the :#{condition} condition") if requires_tree?(condition) && !has_tree?(klass)
22
+
23
+ self.condition = condition
24
+ self.name = self.class.generate_name(column, condition)
25
+ self.klass = klass
26
+ self.column = column.is_a?(String) ? klass.columns_hash[column] : column
27
+ end
28
+
29
+ def explicitly_set_value=(value)
30
+ @explicitly_set_value = value
31
+ end
32
+
33
+ # Need this if someone wants to actually use nil in a meaningful way
34
+ def explicitly_set_value?
35
+ @explicitly_set_value == true
36
+ end
37
+
38
+ def has_tree?(klass = klass)
39
+ !klass.reflect_on_association(:parent).nil?
40
+ end
41
+
42
+ def ignore_blanks?
43
+ ![:equals, :does_not_equal].include?(condition)
44
+ end
45
+
46
+ def quote_column_name(column_name)
47
+ klass.connection.quote_column_name(column_name)
48
+ end
49
+
50
+ def quoted_column_name
51
+ quote_column_name(column.name)
52
+ end
53
+
54
+ def requires_tree?(condition = condition)
55
+ [:child_of, :sibling_of, :descendent_of, :inclusive_descendent_of].include?(condition)
56
+ end
57
+
58
+ def sanitize
59
+ return unless explicitly_set_value?
60
+ v = value
61
+ v = v.utc if false && [:time, :timestamp, :datetime].include?(column.type) && klass.time_zone_aware_attributes && !klass.skip_time_zone_conversion_for_attributes.include?(column.name.to_sym)
62
+ generate_conditions(condition, v)
63
+ end
64
+
65
+ def table_name
66
+ klass.connection.quote_table_name(klass.table_name)
67
+ end
68
+
69
+ def value
70
+ @value.is_a?(String) ? column.type_cast(@value) : @value
71
+ end
72
+
73
+ def value=(v)
74
+ return if ignore_blanks? && v.blank?
75
+ self.explicitly_set_value = true
76
+ @value = v
77
+ end
78
+
79
+ private
80
+ def generate_conditions(condition, value)
81
+ if [:equals, :does_not_equal].include?(condition)
82
+ # Let ActiveRecord handle this
83
+ sql = klass.send(:sanitize_sql_hash_for_conditions, {column.name => value})
84
+ if condition == :does_not_equal
85
+ sql.gsub!(/ IS /, " IS NOT ")
86
+ sql.gsub!(/ BETWEEN /, " NOT BETWEEN ")
87
+ sql.gsub!(/ IN /, " NOT IN ")
88
+ sql.gsub!(/=/, "!=")
89
+ end
90
+ return [sql]
91
+ end
92
+
93
+ strs = []
94
+ subs = []
95
+
96
+ if value.is_a?(Array)
97
+ merge_conditions(*value.collect { |v| generate_conditions(condition, v) })
98
+ else
99
+ case condition
100
+ when :begins_with
101
+ search_parts = value.split(/ /)
102
+ search_parts.each do |search_part|
103
+ strs << "#{table_name}.#{quoted_column_name} LIKE ?"
104
+ subs << "#{search_part}%"
105
+ end
106
+ when :contains
107
+ strs << "#{table_name}.#{quoted_column_name} LIKE ?"
108
+ subs << "%#{value}%"
109
+ when :ends_with
110
+ search_parts = value.split(/ /)
111
+ search_parts.each do |search_part|
112
+ strs << "#{table_name}.#{quoted_column_name} LIKE ?"
113
+ subs << "%#{search_part}"
114
+ end
115
+ when :greater_than
116
+ strs << "#{table_name}.#{quoted_column_name} > ?"
117
+ subs << value
118
+ when :greater_than_or_equal_to
119
+ strs << "#{table_name}.#{quoted_column_name} >= ?"
120
+ subs << value
121
+ when :keywords
122
+ search_parts = value.gsub(/,/, " ").split(/ /).collect { |word| word.downcase.gsub(/[^[:alnum:]]/, ''); }.uniq.select { |word| !BLACKLISTED_WORDS.include?(word.downcase) && !word.blank? }
123
+ search_parts.each do |search_part|
124
+ strs << "#{table_name}.#{quoted_column_name} LIKE ?"
125
+ subs << "%#{search_part}%"
126
+ end
127
+ when :less_than
128
+ strs << "#{table_name}.#{quoted_column_name} < ?"
129
+ subs << value
130
+ when :less_than_or_equal_to
131
+ strs << "#{table_name}.#{quoted_column_name} <= ?"
132
+ subs << value
133
+ when :child_of
134
+ parent_association = klass.reflect_on_association(:parent)
135
+ foreign_key_name = (parent_association && parent_association.options[:foreign_key]) || "parent_id"
136
+ strs << "#{table_name}.#{quote_column_name(foreign_key_name)} = ?"
137
+ subs << value
138
+ when :sibling_of
139
+ parent_association = klass.reflect_on_association(:parent)
140
+ foreign_key_name = (parent_association && parent_association.options[:foreign_key]) || "parent_id"
141
+ parent_id = klass.find(value).send(foreign_key_name)
142
+ return generate_conditions(:child_of, parent_id)
143
+ when :descendent_of
144
+ # Wish I knew how to do this in SQL
145
+ root = klass.find(value) rescue return
146
+ condition_strs = []
147
+ all_children_ids(root).each do |child_id|
148
+ condition_strs << "#{table_name}.#{quote_column_name(klass.primary_key)} = ?"
149
+ subs << child_id
150
+ end
151
+ strs << condition_strs.join(" OR ")
152
+ when :inclusive_descendent_of
153
+ return merge_conditions(["#{table_name}.#{quote_column_name(klass.primary_key)} = ?", value], generate_conditions(:descendent_of, value), :any => true)
154
+ end
155
+
156
+ [strs.join(" AND "), *subs]
157
+ end
158
+ end
159
+
160
+ def all_children_ids(record)
161
+ ids = record.children.collect { |child| child.send(klass.primary_key) }
162
+ record.children.each { |child| ids += all_children_ids(child) }
163
+ ids
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,154 @@
1
+ module BinaryLogic
2
+ module Searchgasm
3
+ module Search
4
+ class Conditions
5
+ include Utilities
6
+
7
+ attr_accessor :klass, :relationship_name, :scope
8
+
9
+ class << self
10
+ def conditions_for_column_type(type)
11
+ condition_names = [:equals, :does_not_equal]
12
+ case type
13
+ when :string, :text
14
+ condition_names += [:begins_with, :contains, :keywords, :ends_with]
15
+ when :integer, :float, :decimal, :datetime, :timestamp, :time, :date
16
+ condition_names += [:greater_than, :greater_than_or_equal_to, :less_than, :less_than_or_equal_to]
17
+ end
18
+ condition_names
19
+ end
20
+
21
+ def alias_conditions(condition)
22
+ case condition
23
+ when :equals then ["", :is]
24
+ when :does_not_equal then [:is_not, :not]
25
+ when :begins_with then [:starts_with]
26
+ when :contains then [:like]
27
+ when :greater_than then [:gt, :after]
28
+ when :greater_than_or_equal_to then [:at_least, :gte]
29
+ when :less_than then [:lt, :before]
30
+ when :less_than_or_equal_to then [:at_most, :lte]
31
+ else []
32
+ end
33
+ end
34
+ end
35
+
36
+ def initialize(klass, values = {})
37
+ self.klass = klass
38
+ klass.columns.each { |column| add_conditions_for_column!(column) }
39
+ klass.reflect_on_all_associations.each { |association| add_association!(association) }
40
+ self.value = values
41
+ end
42
+
43
+ def associations
44
+ objects.select { |object| object.is_a?(self.class) }
45
+ end
46
+
47
+ def includes
48
+ i = []
49
+ associations.each do |association|
50
+ association_includes = association.includes
51
+ i << (association_includes.blank? ? association.relationship_name.to_sym : {association.relationship_name.to_sym => association_includes})
52
+ end
53
+ i.blank? ? nil : (i.size == 1 ? i.first : i)
54
+ end
55
+
56
+ def objects
57
+ @objects ||= []
58
+ end
59
+
60
+ def reset!
61
+ dupped_objects = objects.dup
62
+ dupped_objects.each do |object|
63
+ if object.is_a?(self.class)
64
+ send("reset_#{object.relationship_name}!")
65
+ else
66
+ send("reset_#{object.name}!")
67
+ end
68
+ end
69
+ objects
70
+ end
71
+
72
+ def sanitize
73
+ sanitized_objects = merge_conditions(*objects.collect { |object| object.sanitize })
74
+ return scope if sanitized_objects.blank?
75
+ merge_conditions(sanitized_objects, scope)
76
+ end
77
+
78
+ def value=(conditions)
79
+ reset!
80
+ self.scope = nil
81
+
82
+ case conditions
83
+ when Hash
84
+ conditions.each { |condition, value| send("#{condition}=", value) }
85
+ else
86
+ self.scope = conditions
87
+ end
88
+ end
89
+
90
+ def value
91
+ values_hash = {}
92
+ objects.each do |object|
93
+ next unless object.explicitly_set_value?
94
+ values_hash[object.name.to_sym] = object.value
95
+ end
96
+ values_hash
97
+ end
98
+
99
+ private
100
+ def add_association!(association)
101
+ self.class.class_eval <<-SRC
102
+ def #{association.name}
103
+ if @#{association.name}.nil?
104
+ @#{association.name} = self.class.new(#{association.class_name})
105
+ @#{association.name}.relationship_name = "#{association.name}"
106
+ self.objects << @#{association.name}
107
+ end
108
+ @#{association.name}
109
+ end
110
+
111
+ def #{association.name}=(value); #{association.name}.value = value; end
112
+ def reset_#{association.name}!; objects.delete(#{association.name}); @#{association.name} = nil; end
113
+ SRC
114
+
115
+ association.name
116
+ end
117
+
118
+ def add_conditions_for_column!(column)
119
+ self.class.conditions_for_column_type(column.type).collect { |condition_name| add_condition!(condition_name, column) }
120
+ end
121
+
122
+ def add_condition!(condition_name, column)
123
+ name = Condition.generate_name(column, condition_name)
124
+
125
+ # Define accessor methods
126
+ self.class.class_eval <<-SRC
127
+ def #{name}_object
128
+ if @#{name}.nil?
129
+ @#{name} = Condition.new(:#{condition_name}, klass, "#{column.name}")
130
+ self.objects << @#{name}
131
+ end
132
+ @#{name}
133
+ end
134
+
135
+ def #{name}; #{name}_object.value; end
136
+ def #{name}=(value); #{name}_object.value = value; end
137
+ def reset_#{name}!; objects.delete(#{name}_object); @#{name} = nil; end
138
+ SRC
139
+
140
+ # Define aliases
141
+ self.class.alias_conditions(condition_name).each do |alias_condition_name|
142
+ alias_name = Condition.generate_name(column, alias_condition_name)
143
+ self.class.class_eval do
144
+ alias_method alias_name, name
145
+ alias_method "#{alias_name}=", "#{name}="
146
+ end
147
+ end
148
+
149
+ name
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,34 @@
1
+ module BinaryLogic
2
+ module Searchgasm
3
+ module Search
4
+ module Utilities
5
+ private
6
+ def merge_conditions(*conditions)
7
+ options = conditions.extract_options!
8
+ conditions.delete_if { |condition| condition.blank? }
9
+ return if conditions.blank?
10
+ return conditions.first if conditions.size == 1
11
+
12
+ conditions_strs = []
13
+ conditions_subs = []
14
+
15
+ conditions.each do |condition|
16
+ next if condition.blank?
17
+ arr_condition = [condition].flatten
18
+ conditions_strs << arr_condition.first
19
+ conditions_subs += arr_condition[1..-1]
20
+ end
21
+
22
+ return if conditions_strs.blank?
23
+
24
+ join = options[:any] ? "OR" : "AND"
25
+ conditions_str = "(#{conditions_strs.join(") #{join} (")})"
26
+
27
+ return conditions_str if conditions_subs.blank?
28
+
29
+ [conditions_str, *conditions_subs]
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,82 @@
1
+ # (The MIT License)
2
+ #
3
+ # Copyright (c) 2008 Jamis Buck <jamis@37signals.com>,
4
+ # with modifications by Bruce Williams <bruce@fiveruns.com>
5
+ #
6
+ # Permission is hereby granted, free of charge, to any person obtaining
7
+ # a copy of this software and associated documentation files (the
8
+ # 'Software'), to deal in the Software without restriction, including
9
+ # without limitation the rights to use, copy, modify, merge, publish,
10
+ # distribute, sublicense, and/or sell copies of the Software, and to
11
+ # permit persons to whom the Software is furnished to do so, subject to
12
+ # the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be
15
+ # included in all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
18
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
20
+ # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
21
+ # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
22
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
23
+ # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24
+ module BinaryLogic
25
+
26
+ module Searchgasm
27
+
28
+ # A class for describing the current version of a library. The version
29
+ # consists of three parts: the +major+ number, the +minor+ number, and the
30
+ # +tiny+ (or +patch+) number.
31
+ class Version
32
+
33
+ include Comparable
34
+
35
+ # A convenience method for instantiating a new Version instance with the
36
+ # given +major+, +minor+, and +tiny+ components.
37
+ def self.[](major, minor, tiny)
38
+ new(major, minor, tiny)
39
+ end
40
+
41
+ attr_reader :major, :minor, :tiny
42
+
43
+ # Create a new Version object with the given components.
44
+ def initialize(major, minor, tiny)
45
+ @major, @minor, @tiny = major, minor, tiny
46
+ end
47
+
48
+ # Compare this version to the given +version+ object.
49
+ def <=>(version)
50
+ to_i <=> version.to_i
51
+ end
52
+
53
+ # Converts this version object to a string, where each of the three
54
+ # version components are joined by the '.' character. E.g., 2.0.0.
55
+ def to_s
56
+ @to_s ||= [@major, @minor, @tiny].join(".")
57
+ end
58
+
59
+ # Converts this version to a canonical integer that may be compared
60
+ # against other version objects.
61
+ def to_i
62
+ @to_i ||= @major * 1_000_000 + @minor * 1_000 + @tiny
63
+ end
64
+
65
+ def to_a
66
+ [@major, @minor, @tiny]
67
+ end
68
+
69
+ MAJOR = 0
70
+ MINOR = 9
71
+ TINY = 0
72
+
73
+ # The current version as a Version instance
74
+ CURRENT = new(MAJOR, MINOR, TINY)
75
+ # The current version as a String
76
+ STRING = CURRENT.to_s
77
+
78
+ end
79
+
80
+ end
81
+
82
+ end
data/lib/searchgasm.rb ADDED
@@ -0,0 +1,9 @@
1
+ require "active_record"
2
+ require "searchgasm/active_record/protection"
3
+ require "searchgasm/active_record/base"
4
+ require "searchgasm/active_record/associations"
5
+ require "searchgasm/version"
6
+ require "searchgasm/search/utilities"
7
+ require "searchgasm/search/condition"
8
+ require "searchgasm/search/conditions"
9
+ require "searchgasm/search/base"