bloodhound 0.1 → 0.2

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore CHANGED
@@ -1 +1,2 @@
1
1
  dist
2
+ spec/test.db
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.1"
4
- s.date = "2010-01-15"
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.1"
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
- @mappings = {}
8
+ @search_fields = {}
9
+ @keywords = {}
9
10
  end
10
11
 
11
- def add_search_field(name, type=:string, attribute=name, &mapping)
12
- @mappings[name.to_sym] = [attribute, type.to_sym, mapping]
12
+ def fields
13
+ @search_fields
13
14
  end
14
15
 
15
- def attributes_from(query)
16
- parse_query(query).inject({}) do |conditions, (key,value)|
17
- attribute, type, mapping = @mappings.fetch(key.to_sym, [])
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
- if attribute && type
20
- conditions[attribute.to_s] = (mapping || default_mapping).call(cast_value(value, type))
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
- conditions
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 parse_query(query)
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
- [key, value.compact.first]
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
@@ -1,107 +1,86 @@
1
1
  require "spec_helper"
2
2
 
3
- describe Bloodhound do
4
- context "adding fields" do
5
- it "can specify a type of :string" do
6
- subject.add_search_field(:some_string, :string)
7
- { "some_string" => "falafel" }.should be_included_in_extracted_attributes_from("some_string:falafel")
8
- end
9
-
10
- it "can specify a type of :integer" do
11
- subject.add_search_field(:some_number, :integer)
12
- { "some_number" => 1 }.should be_included_in_extracted_attributes_from("some_number:1")
13
- end
14
-
15
- it "can specify a type of :float" do
16
- subject.add_search_field(:some_number, :float)
17
- { "some_number" => 1.5 }.should be_included_in_extracted_attributes_from("some_number:1.5")
18
- end
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
- it "can specify a type of :date" do
26
- subject.add_search_field(:some_date, :date)
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
- it "can specifiy a type of :time" do
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
- it "can specify a type of :text" do
36
- subject.add_search_field(:some_text, :text)
37
- { "some_text" => "Hello world" }.should be_included_in_extracted_attributes_from("some_text:'Hello world'")
38
- end
25
+ bloodhound do |search|
26
+ search.field :name
27
+ search.field :value
28
+ search.field :available, :type => :boolean, :fuzzy => false
39
29
 
40
- it "defaults to a :string field" do
41
- subject.add_search_field(:some_string)
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
- context "matching quoted values" do
47
- before { subject.add_search_field(:foo) }
48
-
49
- it "matches unquoted values" do
50
- { "foo" => "bar" }.should be_included_in_extracted_attributes_from("foo:bar")
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
- context "casting values" do
69
- it "matches 'yes', 'true', 'y', 'no', 'false', and 'n' as boolean values" do
70
- subject.add_search_field(:foo, :boolean)
71
- { "foo" => true }.should be_included_in_extracted_attributes_from("foo:yes")
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
- it "parses integers as Integer objects" do
80
- subject.add_search_field(:foo, :integer)
81
- subject.add_search_field(:bar, :integer)
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
- it "parses decimal values as Float objects" do
86
- subject.add_search_field(:foo, :float)
87
- { "foo" => 1.5 }.should be_included_in_extracted_attributes_from("foo:1.5")
88
- end
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
- it "parses dates as instances of Date" do
91
- subject.add_search_field(:foo, :date)
92
- { "foo" => Date.today }.should be_included_in_extracted_attributes_from("foo:today")
93
- end
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
- it "parses times as instances of Time" do
96
- subject.add_search_field(:foo, :time)
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
- context "processing matched values with user-defined lambdas" do
102
- it "lets you convert matched values according to your business rules" do
103
- subject.add_search_field(:incomplete, :boolean, "complete") {|value| not value }
104
- { "complete" => false }.should be_included_in_extracted_attributes_from("incomplete:yes")
105
- end
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.attributes_from(attribute_string)
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.1"
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-15 00:00:00 -02:00
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