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 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