gbdev-scoped_search 0.2.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- data/TODO +1 -4
- data/lib/scoped_search.rb +13 -41
- data/lib/scoped_search/query_language_parser.rb +50 -23
- data/test/query_language_test.rb +23 -1
- data/test/search_for_test.rb +37 -13
- data/test/test_helper.rb +18 -15
- metadata +2 -2
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
|
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
|
data/lib/scoped_search.rb
CHANGED
@@ -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 {
|
22
|
-
else
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
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
|
-
|
61
|
-
|
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 = [
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
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 = []
|
data/test/query_language_test.rb
CHANGED
@@ -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)
|
data/test/search_for_test.rb
CHANGED
@@ -11,31 +11,55 @@ class ScopedSearchTest < Test::Unit::TestCase
|
|
11
11
|
teardown_db
|
12
12
|
end
|
13
13
|
|
14
|
-
def test_enabling
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
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
|
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
|
29
|
+
assert_equal 14, SearchTestModel.search_for('o').count
|
31
30
|
assert_equal 2, SearchTestModel.search_for('-o').count
|
32
|
-
assert_equal
|
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
|
data/test/test_helper.rb
CHANGED
@@ -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,
|
26
|
-
create!(:string_field => "Jim", :text_field => "Henson",
|
27
|
-
create!(:string_field => "Jim", :text_field => "Bush",
|
28
|
-
create!(:string_field => "Wes", :text_field => "Hays",
|
29
|
-
create!(:string_field => "Bob", :text_field => "Hays",
|
30
|
-
create!(:string_field => "Dogs", :text_field => "Pit Bull",
|
31
|
-
create!(:string_field => "Dogs", :text_field => "Eskimo",
|
32
|
-
create!(:string_field => "Cows", :text_field => "Farms",
|
33
|
-
create!(:string_field => "Hello World", :text_field => "Hello Moon",
|
34
|
-
create!(:string_field => "Hello World", :text_field => "Goodnight Moon",
|
35
|
-
create!(:string_field => "Happy Cow", :text_field => "Sad Cow",
|
36
|
-
create!(:string_field => "Happy Frog", :text_field => "Sad Frog",
|
37
|
-
create!(:string_field => "Excited Frog", :text_field => "Sad Frog",
|
38
|
-
create!(:string_field => "Man made", :text_field => "Woman made",
|
39
|
-
create!(:string_field => "Cat Toys", :text_field => "Frog Toys",
|
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.
|
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
|
+
date: 2008-09-21 00:00:00 -07:00
|
14
14
|
default_executable:
|
15
15
|
dependencies: []
|
16
16
|
|