gbdev-scoped_search 0.2.0 → 0.3.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.
data/TODO CHANGED
@@ -5,15 +5,12 @@ Contact willem AT vanbergen DOT org if you want to help out
5
5
 
6
6
  New features:
7
7
  - Search fields of associations as well
8
- - Allow other search operators than %LIKE%
8
+ - Allow smart searches. Example: "90 days ago", "Yesterday", "10 days from now"
9
9
 
10
10
  Refactoring:
11
- - Create a separate class to build the actual SQL queries.
12
11
  - For searchable_on(*fields) make it so that instead of fields it accepts options (a hash) where
13
12
  :only and :except can be values. That way it is possible for all fields to be loaded except
14
13
  the ones specified with :except.
15
- - Add checks for field types because the latest version of PostgreSQL (version 8.3.3) is more
16
- strict about searching for strings in columns that are not string types.
17
14
 
18
15
  Documentation & testing:
19
16
  - Put something useful in the wiki
@@ -3,7 +3,9 @@ module ScopedSearch
3
3
  module ClassMethods
4
4
 
5
5
  def self.extended(base)
6
+ require 'scoped_search/reg_tokens'
6
7
  require 'scoped_search/query_language_parser'
8
+ require 'scoped_search/query_conditions_builder'
7
9
  end
8
10
 
9
11
  # Creates a named scope in the class it was called upon
@@ -16,49 +18,19 @@ module ScopedSearch
16
18
  # Build a hash that is used for the named_scope search_for.
17
19
  # This function will split the search_string into keywords, and search for all the keywords
18
20
  # in the fields that were provided to searchable_on
19
- def build_scoped_search_conditions(search_string)
21
+ def build_scoped_search_conditions(search_string)
20
22
  if search_string.nil? || search_string.strip.blank?
21
- return { :conditions => nil }
22
- else
23
- conditions = []
24
- query_params = {}
25
-
26
- QueryLanguageParser.parse(search_string).each_with_index do |search_condition, index|
27
- keyword_name = "keyword_#{index}".to_sym
28
- query_params[keyword_name] = "%#{search_condition.first}%"
29
-
30
- # a keyword may be found in any of the provided fields, so join the conitions with OR
31
- if search_condition.length == 2 && search_condition.last == :not
32
- keyword_conditions = self.scoped_search_fields.map do |field|
33
- field_name = connection.quote_table_name(table_name) + "." + connection.quote_column_name(field)
34
- "(#{field_name} NOT LIKE :#{keyword_name.to_s} OR #{field_name} IS NULL)"
35
- end
36
- conditions << "(#{keyword_conditions.join(' AND ')})"
37
- elsif search_condition.length == 2 && search_condition.last == :or
38
- word1, word2 = query_params[keyword_name].split(' OR ')
39
-
40
- query_params.delete(keyword_name)
41
- keyword_name_a = "#{keyword_name.to_s}a".to_sym
42
- keyword_name_b = "#{keyword_name.to_s}b".to_sym
43
- query_params[keyword_name_a] = word1
44
- query_params[keyword_name_b] = word2
45
-
46
- keyword_conditions = self.scoped_search_fields.map do |field|
47
- field_name = connection.quote_table_name(table_name) + "." + connection.quote_column_name(field)
48
- "(#{field_name} LIKE :#{keyword_name_a.to_s} OR #{field_name} LIKE :#{keyword_name_b.to_s})"
49
- end
50
- conditions << "(#{keyword_conditions.join(' OR ')})"
51
- else
52
- keyword_conditions = self.scoped_search_fields.map do |field|
53
- field_name = connection.quote_table_name(table_name) + "." + connection.quote_column_name(field)
54
- "#{field_name} LIKE :#{keyword_name.to_s}"
55
- end
56
- conditions << "(#{keyword_conditions.join(' OR ')})"
57
- end
23
+ return {:conditions => nil}
24
+ else
25
+ query_fields = {}
26
+ self.scoped_search_fields.each do |field|
27
+ field_name = connection.quote_table_name(table_name) + "." + connection.quote_column_name(field)
28
+ query_fields[field_name] = self.columns_hash[field.to_s].type
58
29
  end
59
-
60
- # all keywords must be matched, so join the conditions with AND
61
- return { :conditions => [conditions.join(' AND '), query_params] }
30
+
31
+ search_conditions = QueryLanguageParser.parse(search_string)
32
+ conditions = QueryConditionsBuilder.build_query(search_conditions, query_fields)
33
+ return {:conditions => conditions}
62
34
  end
63
35
  end
64
36
  end
@@ -23,38 +23,65 @@ module ScopedSearch
23
23
  else
24
24
  if /^.+[ ]OR[ ].+$/ =~ item
25
25
  conditions_tree << [item, :or]
26
+ elsif /^#{RegTokens::BetweenDateFormatMMDDYYYY}$/ =~ item or
27
+ /^#{RegTokens::BetweenDateFormatYYYYMMDD}$/ =~ item or
28
+ /^#{RegTokens::BetweenDatabaseFormat}$/ =~ item
29
+ conditions_tree << [item, :between_dates]
30
+ elsif /^#{RegTokens::GreaterThanOrEqualToDateFormatMMDDYYYY}$/ =~ item or
31
+ /^#{RegTokens::GreaterThanOrEqualToDateFormatYYYYMMDD}$/ =~ item or
32
+ /^#{RegTokens::GreaterThanOrEqualToDatabaseFormat}$/ =~ item
33
+ conditions_tree << [item, :greater_than_or_equal_to_date]
34
+ elsif /^#{RegTokens::LessThanOrEqualToDateFormatMMDDYYYY}$/ =~ item or
35
+ /^#{RegTokens::LessThanOrEqualToDateFormatYYYYMMDD}$/ =~ item or
36
+ /^#{RegTokens::LessThanOrEqualToDatabaseFormat}$/ =~ item
37
+ conditions_tree << [item, :less_than_or_equal_to_date]
38
+ elsif /^#{RegTokens::GreaterThanDateFormatMMDDYYYY}$/ =~ item or
39
+ /^#{RegTokens::GreaterThanDateFormatYYYYMMDD}$/ =~ item or
40
+ /^#{RegTokens::GreaterThanDatabaseFormat}$/ =~ item
41
+ conditions_tree << [item, :greater_than_date]
42
+ elsif /^#{RegTokens::LessThanDateFormatMMDDYYYY}$/ =~ item or
43
+ /^#{RegTokens::LessThanDateFormatYYYYMMDD}$/ =~ item or
44
+ /^#{RegTokens::LessThanDatabaseFormat}$/ =~ item
45
+ conditions_tree << [item, :less_than_date]
46
+ elsif /^#{RegTokens::DateFormatMMDDYYYY}$/ =~ item or
47
+ /^#{RegTokens::DateFormatYYYYMMDD}$/ =~ item or
48
+ /^#{RegTokens::DatabaseFormat}$/ =~ item
49
+ conditions_tree << [item, :as_of_date]
26
50
  else
27
51
  conditions_tree << (negate ? [item, :not] : [item, :like])
28
52
  negate = false
29
53
  end
30
54
  end
31
55
  end
56
+
32
57
  return conditions_tree
33
58
  end
34
-
35
- # **Patterns**
36
- # Each pattern is sperated by a "|". With regular expressions the order of the expression does matter.
37
- #
38
- # ([\w]+[ ]OR[ ][\w]+)
39
- # ([\w]+[ ]OR[ ]["][\w ]+["])
40
- # (["][\w ]+["][ ]OR[ ][\w]+)
41
- # (["][\w ]+["][ ]OR[ ]["][\w ]+["])
42
- # Any two combinations of letters, numbers and underscores that are seperated by " OR " (a single space must
43
- # be on each side of the "OR").
44
- # THESE COULD BE COMBINED BUT BECAUSE OF THE WAY PARSING WORKS THIS IS NOT DONE ON PURPOSE!!
45
- #
46
- # ([-]?[\w]+)
47
- # Any combination of letters, numbers and underscores that may or may not have a dash in front.
48
- #
49
- # ([-]?["][\w ]+["])
50
- # Any combination of letters, numbers, underscores and spaces within double quotes that may or may not have a dash in front.
59
+
51
60
  def tokenize(query)
52
- pattern = ['([\w]+[ ]OR[ ][\w]+)',
53
- '([\w]+[ ]OR[ ]["][\w ]+["])',
54
- '(["][\w ]+["][ ]OR[ ][\w]+)',
55
- '(["][\w ]+["][ ]OR[ ]["][\w ]+["])',
56
- '([-]?[\w]+)',
57
- '([-]?["][\w ]+["])']
61
+ pattern = [RegTokens::BetweenDateFormatMMDDYYYY,
62
+ RegTokens::BetweenDateFormatYYYYMMDD,
63
+ RegTokens::BetweenDatabaseFormat,
64
+ RegTokens::GreaterThanOrEqualToDateFormatMMDDYYYY,
65
+ RegTokens::GreaterThanOrEqualToDateFormatYYYYMMDD,
66
+ RegTokens::GreaterThanOrEqualToDatabaseFormat,
67
+ RegTokens::LessThanOrEqualToDateFormatMMDDYYYY,
68
+ RegTokens::LessThanOrEqualToDateFormatYYYYMMDD,
69
+ RegTokens::LessThanOrEqualToDatabaseFormat,
70
+ RegTokens::GreaterThanDateFormatMMDDYYYY,
71
+ RegTokens::GreaterThanDateFormatYYYYMMDD,
72
+ RegTokens::GreaterThanDatabaseFormat,
73
+ RegTokens::LessThanDateFormatMMDDYYYY,
74
+ RegTokens::LessThanDateFormatYYYYMMDD,
75
+ RegTokens::LessThanDatabaseFormat,
76
+ RegTokens::DateFormatMMDDYYYY,
77
+ RegTokens::DateFormatYYYYMMDD,
78
+ RegTokens::DatabaseFormat,
79
+ RegTokens::WordOrWord,
80
+ RegTokens::WordOrString,
81
+ RegTokens::StringOrWord,
82
+ RegTokens::StringOrString,
83
+ RegTokens::PossiblyNegatedWord,
84
+ RegTokens::PossiblyNegatedString]
58
85
  pattern = Regexp.new(pattern.join('|'))
59
86
 
60
87
  tokens = []
@@ -51,7 +51,7 @@ class QueryLanguageTest < Test::Unit::TestCase
51
51
  assert_equal 2, parsed.length
52
52
  assert_equal 'hallo', parsed[0].first
53
53
  assert_equal 'willem', parsed[1].first
54
-
54
+
55
55
  parsed = parse_query(' "hallo wi"llem"')
56
56
  assert_equal 2, parsed.length
57
57
  assert_equal 'hallo wi', parsed[0].first
@@ -108,6 +108,28 @@ class QueryLanguageTest < Test::Unit::TestCase
108
108
  assert_equal :or, parsed[0][1]
109
109
  end
110
110
 
111
+ def test_as_of_date
112
+ # parsed = parse_query('9/27/1980')
113
+ # assert_equal 1, parsed.length
114
+ # assert_equal '9/27/1980', parsed[0][0]
115
+ # assert_equal :as_of, parsed[0][1]
116
+
117
+ # parsed = parse_query('"Man made" OR Dogs')
118
+ # assert_equal 1, parsed.length
119
+ # assert_equal 'Man made OR Dogs', parsed[0][0]
120
+ # assert_equal :or, parsed[0][1]
121
+ #
122
+ # parsed = parse_query('Cows OR "Frog Toys"')
123
+ # assert_equal 1, parsed.length
124
+ # assert_equal 'Cows OR Frog Toys', parsed[0][0]
125
+ # assert_equal :or, parsed[0][1]
126
+ #
127
+ # parsed = parse_query('"Happy cow" OR "Sad Frog"')
128
+ # assert_equal 1, parsed.length
129
+ # assert_equal 'Happy cow OR Sad Frog', parsed[0][0]
130
+ # assert_equal :or, parsed[0][1]
131
+ end
132
+
111
133
  def test_long_string
112
134
  str = 'Wes -Hays "Hello World" -"Goodnight Moon" Bob OR Wes "Happy cow" OR "Sad Frog" "Man made" OR Dogs Cows OR "Frog Toys"'
113
135
  parsed = parse_query(str)
@@ -11,31 +11,55 @@ class ScopedSearchTest < Test::Unit::TestCase
11
11
  teardown_db
12
12
  end
13
13
 
14
- def test_enabling
15
- assert !SearchTestModel.respond_to?(:search_for)
16
- SearchTestModel.searchable_on :string_field, :text_field
17
- assert SearchTestModel.respond_to?(:search_for)
18
-
19
- assert_equal ActiveRecord::NamedScope::Scope, SearchTestModel.search_for('test').class
20
-
21
- end
22
-
14
+ # def test_enabling
15
+ # assert !SearchTestModel.respond_to?(:search_for)
16
+ # SearchTestModel.searchable_on :string_field, :text_field, :date_field
17
+ # assert SearchTestModel.respond_to?(:search_for)
18
+ #
19
+ # assert_equal ActiveRecord::NamedScope::Scope, SearchTestModel.search_for('test').class
20
+ # end
21
+ #
23
22
  def test_search
24
- SearchTestModel.searchable_on :string_field, :text_field
23
+ SearchTestModel.searchable_on :string_field, :text_field, :date_field
25
24
 
26
- assert_equal 15, SearchTestModel.search_for('').count
25
+ assert_equal 16, SearchTestModel.search_for('').count
27
26
  assert_equal 0, SearchTestModel.search_for('456').count
28
27
  assert_equal 2, SearchTestModel.search_for('hays').count
29
28
  assert_equal 1, SearchTestModel.search_for('hay ob').count
30
- assert_equal 13, SearchTestModel.search_for('o').count
29
+ assert_equal 14, SearchTestModel.search_for('o').count
31
30
  assert_equal 2, SearchTestModel.search_for('-o').count
32
- assert_equal 13, SearchTestModel.search_for('-Jim').count
31
+ assert_equal 14, SearchTestModel.search_for('-Jim').count
33
32
  assert_equal 1, SearchTestModel.search_for('Jim -Bush').count
34
33
  assert_equal 1, SearchTestModel.search_for('"Hello World" -"Goodnight Moon"').count
35
34
  assert_equal 2, SearchTestModel.search_for('Wes OR Bob').count
36
35
  assert_equal 3, SearchTestModel.search_for('"Happy cow" OR "Sad Frog"').count
37
36
  assert_equal 3, SearchTestModel.search_for('"Man made" OR Dogs').count
38
37
  assert_equal 2, SearchTestModel.search_for('Cows OR "Frog Toys"').count
38
+
39
+ # ** DATES **
40
+ #
41
+ # The next two dates are invalid therefore it will be ignored.
42
+ # Thus it would be the same as searching for an empty string
43
+ assert_equal 16, SearchTestModel.search_for('2/30/1980').count
44
+ assert_equal 16, SearchTestModel.search_for('99/99/9999').count
45
+
46
+ assert_equal 1, SearchTestModel.search_for('9/27/1980').count
47
+ assert_equal 1, SearchTestModel.search_for('hays 9/27/1980').count
48
+ assert_equal 2, SearchTestModel.search_for('hays 2/30/1980').count
49
+ assert_equal 1, SearchTestModel.search_for('2006/07/15').count
50
+
51
+ assert_equal 1, SearchTestModel.search_for('< 12/01/1980').count
52
+ assert_equal 5, SearchTestModel.search_for('> 1/1/2006').count
53
+
54
+ assert_equal 5, SearchTestModel.search_for('< 12/26/2002').count
55
+ assert_equal 6, SearchTestModel.search_for('<= 12/26/2002').count
56
+
57
+ assert_equal 5, SearchTestModel.search_for('> 2/5/2005').count
58
+ assert_equal 6, SearchTestModel.search_for('>= 2/5/2005').count
59
+
60
+ assert_equal 3, SearchTestModel.search_for('1/1/2005 TO 1/1/2007').count
61
+
62
+ assert_equal 2, SearchTestModel.search_for('Happy 1/1/2005 TO 1/1/2007').count
39
63
  end
40
64
 
41
65
  end
@@ -1,6 +1,7 @@
1
1
  require 'test/unit'
2
2
  require 'rubygems'
3
3
  require 'active_record'
4
+ require 'ruby-debug'
4
5
 
5
6
  require "#{File.dirname(__FILE__)}/../lib/scoped_search"
6
7
 
@@ -11,6 +12,7 @@ def setup_db
11
12
  t.string :string_field
12
13
  t.text :text_field
13
14
  t.string :ignored_field
15
+ t.date :date_field
14
16
  t.timestamps
15
17
  end
16
18
  end
@@ -22,20 +24,21 @@ end
22
24
 
23
25
  class SearchTestModel < ActiveRecord::Base
24
26
  def self.create_corpus!
25
- create!(:string_field => "Programmer 123", :text_field => nil, :ignored_field => "123456")
26
- create!(:string_field => "Jim", :text_field => "Henson", :ignored_field => "123456a")
27
- create!(:string_field => "Jim", :text_field => "Bush", :ignored_field => "123456b")
28
- create!(:string_field => "Wes", :text_field => "Hays", :ignored_field => "123456c")
29
- create!(:string_field => "Bob", :text_field => "Hays", :ignored_field => "123456d")
30
- create!(:string_field => "Dogs", :text_field => "Pit Bull", :ignored_field => "123456e")
31
- create!(:string_field => "Dogs", :text_field => "Eskimo", :ignored_field => "123456f")
32
- create!(:string_field => "Cows", :text_field => "Farms", :ignored_field => "123456g")
33
- create!(:string_field => "Hello World", :text_field => "Hello Moon", :ignored_field => "123456h")
34
- create!(:string_field => "Hello World", :text_field => "Goodnight Moon", :ignored_field => "123456i")
35
- create!(:string_field => "Happy Cow", :text_field => "Sad Cow", :ignored_field => "123456j")
36
- create!(:string_field => "Happy Frog", :text_field => "Sad Frog", :ignored_field => "123456k")
37
- create!(:string_field => "Excited Frog", :text_field => "Sad Frog", :ignored_field => "123456l")
38
- create!(:string_field => "Man made", :text_field => "Woman made", :ignored_field => "123456m")
39
- create!(:string_field => "Cat Toys", :text_field => "Frog Toys", :ignored_field => "123456n")
27
+ create!(:string_field => "Programmer 123", :text_field => nil, :ignored_field => "123456", :date_field => '2000-01-01')
28
+ create!(:string_field => "Jim", :text_field => "Henson", :ignored_field => "123456a", :date_field => '2001-04-15')
29
+ create!(:string_field => "Jim", :text_field => "Bush", :ignored_field => "123456b", :date_field => '2001-04-17')
30
+ create!(:string_field => "Wes", :text_field => "Hays", :ignored_field => "123456c", :date_field => '1980-09-27')
31
+ create!(:string_field => "Bob", :text_field => "Hays", :ignored_field => "123456d", :date_field => '2002-11-09')
32
+ create!(:string_field => "Dogs", :text_field => "Pit Bull", :ignored_field => "123456e", :date_field => '2002-12-26')
33
+ create!(:string_field => "Dogs", :text_field => "Eskimo", :ignored_field => "123456f", :date_field => '2003-03-19')
34
+ create!(:string_field => "Cows", :text_field => "Farms", :ignored_field => "123456g", :date_field => '2004-05-01')
35
+ create!(:string_field => "Hello World", :text_field => "Hello Moon", :ignored_field => "123456h", :date_field => '2004-07-11')
36
+ create!(:string_field => "Hello World", :text_field => "Goodnight Moon", :ignored_field => "123456i", :date_field => '2004-09-12')
37
+ create!(:string_field => "Happy Cow", :text_field => "Sad Cow", :ignored_field => "123456j", :date_field => '2005-02-05')
38
+ create!(:string_field => "Happy Frog", :text_field => "Sad Frog", :ignored_field => "123456k", :date_field => '2006-03-09')
39
+ create!(:string_field => "Excited Frog", :text_field => "Sad Frog", :ignored_field => "123456l", :date_field => '2006-07-15')
40
+ create!(:string_field => "Man made", :text_field => "Woman made", :ignored_field => "123456m", :date_field => '2007-06-13')
41
+ create!(:string_field => "Cat Toys", :text_field => "Frog Toys", :ignored_field => "123456n", :date_field => '2008-03-04')
42
+ create!(:string_field => "Happy Toys", :text_field => "Sad Toys", :ignored_field => "123456n", :date_field => '2008-05-12')
40
43
  end
41
44
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gbdev-scoped_search
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Willem van Bergen
@@ -10,7 +10,7 @@ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
12
 
13
- date: 2008-09-13 00:00:00 -07:00
13
+ date: 2008-09-21 00:00:00 -07:00
14
14
  default_executable:
15
15
  dependencies: []
16
16