bloodhound 0.1 → 0.2
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 +1 -0
- data/README.markdown +9 -0
- data/bloodhound.gemspec +2 -4
- data/lib/bloodhound.rb +91 -14
- data/spec/bloodhound_spec.rb +67 -88
- data/spec/spec_helper.rb +27 -1
- metadata +2 -4
- data/lib/bloodhound/active_record.rb +0 -43
- data/spec/bloodhound/active_record_spec.rb +0 -44
data/.gitignore
CHANGED
data/README.markdown
CHANGED
@@ -83,6 +83,15 @@ Any extra options you pass to `search_field` are added into the finder options:
|
|
83
83
|
attributes #=> { :joins => :user,
|
84
84
|
:conditions => { "users.login" => "foca" } }
|
85
85
|
|
86
|
+
Install it
|
87
|
+
----------
|
88
|
+
|
89
|
+
gem install bloodhound
|
90
|
+
|
91
|
+
For the active record interface:
|
92
|
+
|
93
|
+
config.gem "bloodhound", :lib => "bloodhound/active_record"
|
94
|
+
|
86
95
|
Known problems
|
87
96
|
--------------
|
88
97
|
|
data/bloodhound.gemspec
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = "bloodhound"
|
3
|
-
s.version = "0.
|
4
|
-
s.date = "2010-01
|
3
|
+
s.version = "0.2"
|
4
|
+
s.date = "2010-02-01"
|
5
5
|
|
6
6
|
s.description = "Convert strings like 'user:foca age:23' to { 'user' => 'foca' => 'age' => 23 }"
|
7
7
|
s.summary = "Convert strings like 'user:foca age:23' to { 'user' => 'foca' => 'age' => 23 }"
|
@@ -18,10 +18,8 @@ Gem::Specification.new do |s|
|
|
18
18
|
README.markdown
|
19
19
|
bloodhound.gemspec
|
20
20
|
lib/bloodhound.rb
|
21
|
-
lib/bloodhound/active_record.rb
|
22
21
|
spec/spec_helper.rb
|
23
22
|
spec/bloodhound_spec.rb
|
24
|
-
spec/bloodhound/active_record_spec.rb
|
25
23
|
]
|
26
24
|
end
|
27
25
|
|
data/lib/bloodhound.rb
CHANGED
@@ -1,28 +1,82 @@
|
|
1
1
|
require "chronic"
|
2
2
|
|
3
3
|
class Bloodhound
|
4
|
-
VERSION = "0.
|
5
|
-
ATTRIBUTE_RE = /\s*(\S+):(?:"([^"]*)"|'([^']*)'|(\S+))\s*/.freeze
|
4
|
+
VERSION = "0.2"
|
5
|
+
ATTRIBUTE_RE = /\s*(?:(?:(\S+):)?(?:"([^"]*)"|'([^']*)'|(\S+)))\s*/.freeze
|
6
6
|
|
7
7
|
def initialize
|
8
|
-
@
|
8
|
+
@search_fields = {}
|
9
|
+
@keywords = {}
|
9
10
|
end
|
10
11
|
|
11
|
-
def
|
12
|
-
@
|
12
|
+
def fields
|
13
|
+
@search_fields
|
13
14
|
end
|
14
15
|
|
15
|
-
def
|
16
|
-
|
17
|
-
|
16
|
+
def field(name, options={}, &mapping)
|
17
|
+
attribute = options.delete(:attribute) || name
|
18
|
+
fuzzy = options.delete(:fuzzy)
|
19
|
+
type = options.delete(:type).try(:to_sym)
|
18
20
|
|
19
|
-
|
20
|
-
|
21
|
+
fields[name.to_sym] = {
|
22
|
+
:attribute => attribute.to_s,
|
23
|
+
:type => type,
|
24
|
+
:fuzzy => fuzzy.nil? ? true : fuzzy,
|
25
|
+
:options => options,
|
26
|
+
:mapping => mapping || default_mapping
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
def keyword(name, &block)
|
31
|
+
@keywords[name.to_sym] = block
|
32
|
+
end
|
33
|
+
|
34
|
+
def search_scope(model, query)
|
35
|
+
tokenize(query).inject(model) do |model, (key,value)|
|
36
|
+
if value.nil?
|
37
|
+
text_search_for(model, key)
|
38
|
+
elsif has_keyword?(key)
|
39
|
+
keyword_search_for(model, key, value)
|
40
|
+
else
|
41
|
+
attribute_search_for(model, key, value)
|
21
42
|
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def text_search_for(model, value)
|
47
|
+
return model if fields.empty?
|
22
48
|
|
23
|
-
|
49
|
+
model = scoped_by_text_search_options(model)
|
50
|
+
|
51
|
+
text_search_conditions = fields.inject([[], {}]) do |conditions, (name,properties)|
|
52
|
+
if properties[:fuzzy]
|
53
|
+
conditions[0] << "#{properties[:attribute]} LIKE :#{name}"
|
54
|
+
conditions[1].update(name => properties[:mapping].call("%#{value}%"))
|
55
|
+
conditions
|
56
|
+
else
|
57
|
+
conditions
|
58
|
+
end
|
24
59
|
end
|
60
|
+
|
61
|
+
model = model.scoped(:conditions => [text_search_conditions[0].join(" OR "),
|
62
|
+
text_search_conditions[1]])
|
63
|
+
end
|
64
|
+
private :text_search_for
|
65
|
+
|
66
|
+
def attribute_search_for(model, name, value)
|
67
|
+
properties = fields.fetch(name.to_sym, {})
|
68
|
+
|
69
|
+
return model if properties[:type].nil?
|
70
|
+
|
71
|
+
value = properties[:mapping].call(cast_value(value, properties[:type]))
|
72
|
+
model.scoped(properties[:options].merge(:conditions => { properties[:attribute] => value }))
|
73
|
+
end
|
74
|
+
private :attribute_search_for
|
75
|
+
|
76
|
+
def keyword_search_for(model, keyword, value)
|
77
|
+
model.scoped(@keywords[keyword].call(value))
|
25
78
|
end
|
79
|
+
private :keyword_search_for
|
26
80
|
|
27
81
|
def default_mapping
|
28
82
|
lambda {|value| value }
|
@@ -54,10 +108,33 @@ class Bloodhound
|
|
54
108
|
end
|
55
109
|
private :cast_value
|
56
110
|
|
57
|
-
def
|
111
|
+
def scoped_by_text_search_options(model)
|
112
|
+
fields.inject(model) do |model, (name,properties)|
|
113
|
+
properties[:fuzzy] ? model.scoped(properties[:options]) : model
|
114
|
+
end
|
115
|
+
end
|
116
|
+
private :scoped_by_text_search_options
|
117
|
+
|
118
|
+
def tokenize(query)
|
58
119
|
query.scan(ATTRIBUTE_RE).map do |(key,*value)|
|
59
|
-
|
120
|
+
value = value.compact.first
|
121
|
+
[key || value, key && value]
|
122
|
+
end
|
123
|
+
end
|
124
|
+
private :tokenize
|
125
|
+
|
126
|
+
def has_keyword?(name)
|
127
|
+
@keywords.has_key?(name)
|
128
|
+
end
|
129
|
+
|
130
|
+
module Searchable
|
131
|
+
def bloodhound(&block)
|
132
|
+
@bloodhound ||= Bloodhound.new
|
133
|
+
block ? @bloodhound.tap(&block) : @bloodhound
|
134
|
+
end
|
135
|
+
|
136
|
+
def scoped_search(query)
|
137
|
+
bloodhound.search_scope(self, query)
|
60
138
|
end
|
61
139
|
end
|
62
|
-
private :parse_query
|
63
140
|
end
|
data/spec/bloodhound_spec.rb
CHANGED
@@ -1,107 +1,86 @@
|
|
1
1
|
require "spec_helper"
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
it "can specify a type of :boolean" do
|
21
|
-
subject.add_search_field(:some_boolean, :boolean)
|
22
|
-
{ "some_boolean" => true }.should be_included_in_extracted_attributes_from("some_boolean:true")
|
23
|
-
end
|
3
|
+
class User < ActiveRecord::Base
|
4
|
+
extend Bloodhound::Searchable
|
5
|
+
|
6
|
+
has_many :products
|
7
|
+
|
8
|
+
bloodhound do |search|
|
9
|
+
search.field(:first_name, :attribute => "lower(users.first_name)") {|value| value.downcase }
|
10
|
+
search.field :last_name
|
11
|
+
search.field :product, :attribute => "products.name", :joins => :products, :group => column_names.join(",")
|
12
|
+
search.field :available_product, :type => :boolean,
|
13
|
+
:attribute => "products.available",
|
14
|
+
:fuzzy => false,
|
15
|
+
:joins => :products,
|
16
|
+
:group => column_names.join(",")
|
17
|
+
end
|
18
|
+
end
|
24
19
|
|
25
|
-
|
26
|
-
|
27
|
-
{ "some_date" => Date.parse('2010-01-05') }.should be_included_in_extracted_attributes_from("some_date:2010-01-05")
|
28
|
-
end
|
20
|
+
class Product < ActiveRecord::Base
|
21
|
+
extend Bloodhound::Searchable
|
29
22
|
|
30
|
-
|
31
|
-
subject.add_search_field(:some_time, :time)
|
32
|
-
{ "some_time" => Time.mktime(2010, 1, 5, 12, 0, 0) }.should be_included_in_extracted_attributes_from("some_time:'2010-01-05 12:00:00'")
|
33
|
-
end
|
23
|
+
belongs_to :user
|
34
24
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
25
|
+
bloodhound do |search|
|
26
|
+
search.field :name
|
27
|
+
search.field :value
|
28
|
+
search.field :available, :type => :boolean, :fuzzy => false
|
39
29
|
|
40
|
-
|
41
|
-
|
42
|
-
{ "some_string" => "falafel" }.should be_included_in_extracted_attributes_from("some_string:falafel")
|
30
|
+
search.keyword :sort do |value|
|
31
|
+
{ :order => "#{search.fields[value.to_sym][:attribute]} DESC" }
|
43
32
|
end
|
44
33
|
end
|
34
|
+
end
|
45
35
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
{ "foo" => "b'a'r" }.should be_included_in_extracted_attributes_from("foo:b'a'r")
|
52
|
-
{ "foo" => "ba'r" }.should be_included_in_extracted_attributes_from("foo:ba'r")
|
53
|
-
end
|
54
|
-
|
55
|
-
it "matches values within double quotes" do
|
56
|
-
{ "foo" => "this is awesome" }.should be_included_in_extracted_attributes_from(%Q(foo:"this is awesome"))
|
57
|
-
{ "foo" => "this is 'tricky'" }.should be_included_in_extracted_attributes_from(%Q(foo:"this is 'tricky'"))
|
58
|
-
{ "foo" => "'this is even trickier'" }.should be_included_in_extracted_attributes_from(%Q(foo:"'this is even trickier'"))
|
59
|
-
end
|
60
|
-
|
61
|
-
it "matches values within single quotes" do
|
62
|
-
{ "foo" => 'this is awesome' }.should be_included_in_extracted_attributes_from(%Q(foo:'this is awesome'))
|
63
|
-
{ "foo" => 'this is "tricky"' }.should be_included_in_extracted_attributes_from(%Q(foo:'this is "tricky"'))
|
64
|
-
{ "foo" => '"this is even trickier"' }.should be_included_in_extracted_attributes_from(%Q(foo:'"this is even trickier"'))
|
65
|
-
end
|
36
|
+
describe Bloodhound do
|
37
|
+
before :all do
|
38
|
+
@john = User.create(:first_name => "John", :last_name => "Doe")
|
39
|
+
@tv = @john.products.create(:name => "Rubber Duck", :value => 10)
|
40
|
+
@ducky = @john.products.create(:name => "TV", :value => 200)
|
66
41
|
end
|
67
42
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
{ "foo" => true }.should be_included_in_extracted_attributes_from("foo:y")
|
73
|
-
{ "foo" => true }.should be_included_in_extracted_attributes_from("foo:true")
|
74
|
-
{ "foo" => false }.should be_included_in_extracted_attributes_from("foo:no")
|
75
|
-
{ "foo" => false }.should be_included_in_extracted_attributes_from("foo:n")
|
76
|
-
{ "foo" => false }.should be_included_in_extracted_attributes_from("foo:false")
|
77
|
-
end
|
43
|
+
it "finds a user by first_name, in a case insensitive manner" do
|
44
|
+
User.scoped_search("John").should include(@john)
|
45
|
+
User.scoped_search("joHN").should include(@john)
|
46
|
+
end
|
78
47
|
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
{ "foo" => 1, "bar" => 2 }.should be_included_in_extracted_attributes_from("foo:1 bar:2.8")
|
83
|
-
end
|
48
|
+
it "uses LIKE '%query%' for fuzzy searches" do
|
49
|
+
User.scoped_search("oh").should include(@john)
|
50
|
+
end
|
84
51
|
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
52
|
+
it "finds a user by last_name, but only in a case sensitive manner" do
|
53
|
+
User.scoped_search("Doe").should include(@john)
|
54
|
+
User.scoped_search("Doe").should have(1).element
|
55
|
+
User.scoped_search("Smith").should_not include(@john)
|
56
|
+
end
|
89
57
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
58
|
+
it "can find using key:value pairs for attribuets that define a type" do
|
59
|
+
User.scoped_search("available_product:yes").should include(@john)
|
60
|
+
User.scoped_search("available_product:no").should_not include(@john)
|
61
|
+
end
|
94
62
|
|
95
|
-
|
96
|
-
|
97
|
-
{ "foo" => Time.mktime(2010, 1, 5, 12, 0, 0) }.should be_included_in_extracted_attributes_from("foo:2010-01-05 12:00:00")
|
98
|
-
end
|
63
|
+
it "allows defining arbitrary keywords to create scopes" do
|
64
|
+
@john.products.scoped_search("order:name").all.should == [@tv, @ducky]
|
99
65
|
end
|
100
66
|
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
67
|
+
it "exposes the fields via bloodhound#attributes" do
|
68
|
+
last_name = User.bloodhound.fields[:last_name]
|
69
|
+
last_name[:attribute].should == "last_name"
|
70
|
+
last_name[:type].should be_nil
|
71
|
+
last_name[:fuzzy].should be_true
|
72
|
+
last_name[:options].should == {}
|
73
|
+
|
74
|
+
first_name = User.bloodhound.fields[:first_name]
|
75
|
+
first_name[:attribute].should == "lower(users.first_name)"
|
76
|
+
first_name[:type].should be_nil
|
77
|
+
first_name[:fuzzy].should be_true
|
78
|
+
first_name[:options].should == {}
|
79
|
+
|
80
|
+
availability = Product.bloodhound.fields[:available]
|
81
|
+
availability[:attribute].should == "available"
|
82
|
+
availability[:type].should == :boolean
|
83
|
+
availability[:fuzzy].should be_false
|
84
|
+
availability[:options].should == {}
|
106
85
|
end
|
107
86
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -1,12 +1,38 @@
|
|
1
1
|
require "spec"
|
2
2
|
require "ostruct"
|
3
3
|
require "bloodhound"
|
4
|
+
require "active_record"
|
5
|
+
|
6
|
+
ActiveRecord::Base.establish_connection(
|
7
|
+
:adapter => "sqlite3",
|
8
|
+
:database => "spec/test.db"
|
9
|
+
)
|
10
|
+
|
11
|
+
#ActiveRecord::Base.logger = Logger.new(STDOUT)
|
12
|
+
|
13
|
+
ActiveRecord::Base.connection.tables.each do |table|
|
14
|
+
ActiveRecord::Base.connection.drop_table(table)
|
15
|
+
end
|
16
|
+
|
17
|
+
ActiveRecord::Schema.define(:version => 1) do
|
18
|
+
create_table :users do |t|
|
19
|
+
t.string :first_name
|
20
|
+
t.string :last_name
|
21
|
+
end
|
22
|
+
|
23
|
+
create_table :products do |t|
|
24
|
+
t.string :name
|
25
|
+
t.integer :value
|
26
|
+
t.boolean :available, :default => true
|
27
|
+
t.belongs_to :user
|
28
|
+
end
|
29
|
+
end
|
4
30
|
|
5
31
|
Spec::Runner.configure do |config|
|
6
32
|
module CustomMatchers
|
7
33
|
def be_included_in_extracted_attributes_from(attribute_string)
|
8
34
|
simple_matcher :other_set_of_attributes do |given|
|
9
|
-
expected = subject.
|
35
|
+
expected = subject.tokenize(attribute_string)
|
10
36
|
given.each do |key, value|
|
11
37
|
expected.fetch(key).should == value
|
12
38
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: bloodhound
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: "0.
|
4
|
+
version: "0.2"
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- "Nicol\xC3\xA1s Sanguinetti"
|
@@ -9,7 +9,7 @@ autorequire:
|
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
11
|
|
12
|
-
date: 2010-01
|
12
|
+
date: 2010-02-01 00:00:00 -02:00
|
13
13
|
default_executable:
|
14
14
|
dependencies: []
|
15
15
|
|
@@ -26,10 +26,8 @@ files:
|
|
26
26
|
- README.markdown
|
27
27
|
- bloodhound.gemspec
|
28
28
|
- lib/bloodhound.rb
|
29
|
-
- lib/bloodhound/active_record.rb
|
30
29
|
- spec/spec_helper.rb
|
31
30
|
- spec/bloodhound_spec.rb
|
32
|
-
- spec/bloodhound/active_record_spec.rb
|
33
31
|
has_rdoc: false
|
34
32
|
homepage: http://github.com/foca/bloodhound
|
35
33
|
licenses: []
|
@@ -1,43 +0,0 @@
|
|
1
|
-
require "bloodhound"
|
2
|
-
|
3
|
-
module ActiveRecord
|
4
|
-
class Bloodhound < ::Bloodhound
|
5
|
-
def initialize
|
6
|
-
super()
|
7
|
-
@extra_options = {}
|
8
|
-
end
|
9
|
-
|
10
|
-
def add_search_field(name, type=:string, attribute=name, options={}, &mapping)
|
11
|
-
super(name, type, attribute, &mapping)
|
12
|
-
@extra_options[attribute.to_s] = options
|
13
|
-
end
|
14
|
-
|
15
|
-
def attributes_from(query)
|
16
|
-
super(query).inject({}) do |finder_options, (name, value)|
|
17
|
-
finder_options[:conditions] ||= {}
|
18
|
-
finder_options[:conditions].update(name => value)
|
19
|
-
finder_options.update(@extra_options.fetch(name, {}))
|
20
|
-
finder_options
|
21
|
-
end
|
22
|
-
end
|
23
|
-
end
|
24
|
-
end
|
25
|
-
|
26
|
-
module Bloodhound::Searchable
|
27
|
-
def bloodhound
|
28
|
-
@bloodhound ||= ActiveRecord::Bloodhound.new
|
29
|
-
end
|
30
|
-
|
31
|
-
def search_field(name, options={}, &mapping)
|
32
|
-
attribute = options.delete(:attribute) || name
|
33
|
-
type = options.delete(:type) || :string
|
34
|
-
bloodhound.add_search_field(name, type, attribute, options, &mapping)
|
35
|
-
end
|
36
|
-
|
37
|
-
def self.extended(model)
|
38
|
-
model.columns.each do |column|
|
39
|
-
next if column.name.to_s =~ /_?id$/
|
40
|
-
model.search_field column.name, :type => column.type
|
41
|
-
end
|
42
|
-
end
|
43
|
-
end
|
@@ -1,44 +0,0 @@
|
|
1
|
-
require "spec_helper"
|
2
|
-
require "bloodhound/active_record"
|
3
|
-
|
4
|
-
describe ActiveRecord::Bloodhound do
|
5
|
-
it "returns a hash with { :conditions => {keys matched} } instead of just the keys" do
|
6
|
-
subject.add_search_field(:foo)
|
7
|
-
subject.attributes_from("foo:bar").should == { :conditions => { "foo" => "bar" } }
|
8
|
-
end
|
9
|
-
|
10
|
-
it "allows for extra options, that get merged into the return hash" do
|
11
|
-
subject.add_search_field(:user, :string, "users.name", :joins => :users)
|
12
|
-
subject.attributes_from("user:'John Doe'").should == {
|
13
|
-
:joins => :users,
|
14
|
-
:conditions => { "users.name" => "John Doe" }
|
15
|
-
}
|
16
|
-
end
|
17
|
-
end
|
18
|
-
|
19
|
-
describe Bloodhound::Searchable do
|
20
|
-
Column = Struct.new(:name, :type)
|
21
|
-
|
22
|
-
class MockModel
|
23
|
-
def self.columns
|
24
|
-
[Column.new(:id, :integer), Column.new(:name, :string), Column.new(:user_id, :integer)]
|
25
|
-
end
|
26
|
-
|
27
|
-
extend Bloodhound::Searchable
|
28
|
-
end
|
29
|
-
|
30
|
-
it "allows accessing the bloodhound object in the model by calling Model.bloodhound" do
|
31
|
-
MockModel.bloodhound.should be_an(ActiveRecord::Bloodhound)
|
32
|
-
end
|
33
|
-
|
34
|
-
it "adds search fields for the non-id fields in the model" do
|
35
|
-
conditions = MockModel.bloodhound.attributes_from("name:John id:3 user_id:5").fetch(:conditions)
|
36
|
-
conditions.should_not have_key("id")
|
37
|
-
conditions.should_not have_key("user_id")
|
38
|
-
end
|
39
|
-
|
40
|
-
it "allows you to add search fields with a more ActiveRecord-ish syntax" do
|
41
|
-
MockModel.search_field :user, :type => :string, :attribute => "users.name"
|
42
|
-
MockModel.bloodhound.attributes_from("user:John").should == { :conditions => { "users.name" => "John" } }
|
43
|
-
end
|
44
|
-
end
|