search_magic 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ pkg/*
2
+ *.gem
3
+ .bundle
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in search_magic.gemspec
4
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,47 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ search_magic (0.0.1)
5
+ mongoid (>= 2.0.0.rc.7)
6
+
7
+ GEM
8
+ remote: http://rubygems.org/
9
+ specs:
10
+ activemodel (3.0.4)
11
+ activesupport (= 3.0.4)
12
+ builder (~> 2.1.2)
13
+ i18n (~> 0.4)
14
+ activesupport (3.0.4)
15
+ bson (1.2.4)
16
+ bson_ext (1.2.4)
17
+ builder (2.1.2)
18
+ database_cleaner (0.6.4)
19
+ diff-lcs (1.1.2)
20
+ i18n (0.5.0)
21
+ mongo (1.2.4)
22
+ bson (>= 1.2.4)
23
+ mongoid (2.0.0.rc.7)
24
+ activemodel (~> 3.0)
25
+ mongo (~> 1.2)
26
+ tzinfo (~> 0.3.22)
27
+ will_paginate (~> 3.0.pre)
28
+ rspec (2.5.0)
29
+ rspec-core (~> 2.5.0)
30
+ rspec-expectations (~> 2.5.0)
31
+ rspec-mocks (~> 2.5.0)
32
+ rspec-core (2.5.1)
33
+ rspec-expectations (2.5.0)
34
+ diff-lcs (~> 1.1.2)
35
+ rspec-mocks (2.5.0)
36
+ tzinfo (0.3.24)
37
+ will_paginate (3.0.pre2)
38
+
39
+ PLATFORMS
40
+ ruby
41
+
42
+ DEPENDENCIES
43
+ bson_ext
44
+ database_cleaner
45
+ mongoid (>= 2.0.0.rc.7)
46
+ rspec
47
+ search_magic!
data/README.textile ADDED
@@ -0,0 +1,129 @@
1
+ h1. SearchMagic
2
+
3
+ SearchMagic provides full-text search capabilities to "mongoid":http://github.com/mongoid/mongoid documents, embedded documents, and referenced documents with a clean, consistent, and easy to use syntax. Searching can be performed on either word fragments, such as *foo*, or can use a selector-syntax, *foo:bar*, to target which fields of the document the search is to be restricted to.
4
+
5
+ h2. Installation
6
+
7
+ SearchMagic is built on top of the latest pre-release version of mongoid; in all likelihood, it will only work with versions greater than or equal to _2.0.0.rc.7_. For environments where bundler is being used, it can be installed by adding the following to your Gemfile and running @bundle@.
8
+
9
+ bc. gem 'search_magic'
10
+
11
+ h2. Getting Started
12
+
13
+ h3. Adding FullTextSearch capabilities
14
+
15
+ Adding FullTextSearch is as simple as including the appropriate module into a mongoid document and defining which fields are to be searchable. In the following example, the *SearchMagic::FullTextSearch* module is included and each field of the model is made searchable.
16
+
17
+ bc.. class Address
18
+ include Mongoid::Document
19
+ include SearchMagic::FullTextSearch
20
+ field :street
21
+ field :city
22
+ field :state
23
+ field :post_code
24
+ embedded_in :person
25
+
26
+ search_on :street
27
+ search_on :city
28
+ search_on :state
29
+ search_on :post_code
30
+ end
31
+
32
+ p. At this point, *Address* can be searched by calling its @:search@ method:
33
+
34
+ bc. Address.search("state:ca")
35
+
36
+ h3. :search_on
37
+
38
+ Fields that are made searchable by :search_on have their values cached in an embedded array within each document. This array, *:searchable_values*, should contain entries of the form *field_name:value*. The selector, *field_name*, represents a filter which can be used when searching to narrow the search space; it can be manually renamed by passing the *:as* option to :search_on:
39
+
40
+ bc. search_on :post_code, :as => :zip_code
41
+
42
+ The example in the previous section showcased using :search_on on basic *Mongoid::Document* fields. It can, however, be used on fields within a document which denote an association.
43
+
44
+ bc.. class Person
45
+ include Mongoid::Document
46
+ include SearchMagic::FullTextSearch
47
+ field :name
48
+ embeds_one :address
49
+
50
+ search_on :name
51
+ search_on :address
52
+ end
53
+
54
+ p. When an association is searched on, all of its searchable fields are automatically made searchable in the first document. In the previous example, this means that the four fields of *Address*, @[:street, :city, :state, :post_code]@ are now searchable from within *Person*. As such, each association will end up adding entries into the *:searchable_values* array. The searchable fields which are introduced from an association can be restricted by use of the *:only* and *:except* options, which may either take an array or an individual field name:
55
+
56
+ bc. search_on :address, :only => [:street, :state]
57
+ search_on :address, :except => :post_code
58
+
59
+ By default, an association's fields will be prefixed by name of the association. Therefore, the previous example would add entries to *:searchable_values* with the selectors @[:address_street, :address_city, :address_state, :address_post_code]@. The *:as* option alters the prefix:
60
+
61
+ bc. search_on :address, :as => :location # results in :location_street, :location_city, ...
62
+
63
+ It is also possible to prevent the prefix from being added to each absorbed searchable field through use of the *:skip_prefix* option:
64
+
65
+ bc. search_on :address, :skip_prefix => true # results in :street, :city, ...
66
+
67
+ :skip_prefix and :as cannot be used concurrently: :skip_prefix will always take precedence.
68
+
69
+ Values added to *:searchable_values* automatically are split on whitespace and have their punctuation removed. For most cases, searches performed on models are not going to need punctuation support. However, if it is desired to keep the punctuation present on a particular field, that can easily be done through the *:keep_punctuation* option:
70
+
71
+ bc.. class Asset
72
+ include Mongoid::Document
73
+ include SearchMagic::FullTextSearch
74
+ field :tags, :type => Array
75
+
76
+ search_on :tags, :keep_punctuation => true
77
+ end
78
+
79
+ p. Now all entries within *:searchable_values* for *:tags* will retain meaningful punctuation. The previous example is interesting for another reason: embedded arrays are handled specially. Specifically, the selector for an embedded array will be singularized. In the case of the previous example, this would result in a selector of "tag".
80
+
81
+ Finally, it should be noted that nesting of searchable documents is possible. (Currently, cyclic searches, where, for documents A and B, A searching on B which also searches on A should be avoided. Doing so is not prohibited, but will likely cause the environment to crash.) If a given document searches on an association with another document which, in and of itself, searches on a third document, the first automatically has access to the third document's searchable fields.
82
+
83
+ bc.. class Part
84
+ include Mongoid::Document
85
+ include SearchMagic::FullTextSearch
86
+ field :serial
87
+ references_in :part_number
88
+
89
+ search_on :serial
90
+ search_on :part_number, :skip_prefix => true
91
+ end
92
+
93
+ class PartNumber
94
+ include Mongoid::Document
95
+ include SearchMagic::FullTextSearch
96
+ field :value
97
+ references_many :parts
98
+ referenced_in :part_category
99
+
100
+ search_on :number
101
+ search_on :part_category, :as => :category
102
+ end
103
+
104
+ class PartCategory
105
+ include Mongoid::Document
106
+ include SearchMagic::FullTextSearch
107
+ field :name
108
+ references_many :part_numbers
109
+
110
+ search_on :name
111
+ end
112
+
113
+ p. *PartNumber* will be able to search on both _:number_ and _:category_name_. *Part*, on the other hand, will absorb all of the searchable fields of PartNumber, including its associations. So, it can be searched on _:serial_, _:number_, and _:category_name_
114
+
115
+ h3. :search
116
+
117
+ Searching a model with SearchMagic is simple: each model gains a class method called _:search_ which accepts one parameter, the search pattern. This method is a "mongoid scope":http://mongoid.org/docs/querying/; it will always return a criteria object after executing. As such, it plays nicely with other scopes on your models.
118
+
119
+ SearchMagic expects the incoming _pattern_ to be a string containing whitespace delimited phrases. Each phrase can either be a single word, or a _selector:value_ pair. Multiple phrases will narrow the search field: each additional phrase places an additional requirement on a matching document. Single word phrases are matched across all entries in a model's _:searchable_values_ array. The pairs, on the other hand, restrict the search for _value_ against only those entries which match _selector_. In either case, _word_ or _value_ may contain fragments of whole entries stored within _:searchable_values_.
120
+
121
+ Using the models defined in the previous section, the following searches are all perfectly valid:
122
+
123
+ bc. Part.search("table") # full text search on "table"
124
+ Part.search("category_name:table") # restricts the search for "table" to "category_name"
125
+ Part.search("bike serial:b1234") # full text search on "bike", with an extra requirement that the serial be "b1234"
126
+
127
+ h2. Problems? Comments?
128
+
129
+ Feel free to add an "issue on GitHub":search_magic/issues or fork the project and send a pull request. I'm always looking for new ways of bending hardware to my will, so suggestions are welcome.
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
@@ -0,0 +1,104 @@
1
+ module SearchMagic
2
+ module FullTextSearch
3
+ module ClassMethods
4
+ def self.extended(receiver)
5
+ receiver.send :class_attribute, :searchable_fields, :instance_writer => false
6
+ receiver.send :searchable_fields=, {}
7
+ receiver.send :field, :searchable_values, :type => Array, :default => []
8
+ receiver.send :before_save, :update_searchable_values
9
+ end
10
+
11
+ def search_on(field_name, options = {})
12
+ metadata = reflect_on_association(field_name)
13
+ send(:searchable_fields)[field_name] = Metadata.new(:field_name => field_name, :field => fields[field_name.to_s], :association => metadata, :options => options)
14
+ end
15
+
16
+ def searchables
17
+ @searchables ||= Hash[*searchable_fields.values.map {|metadata| metadata.searchable_names(nil).map {|a| [a.first, a.last]}}.flatten].tap do |hash|
18
+ hash.keys.each do |name|
19
+ if self.method_defined?(name) && reflect_on_association(name).nil?
20
+ alias_method(:"_#{name}", name)
21
+ else
22
+ define_method(:"_#{name}") {find_searchable_value(name)}
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ def search(pattern)
29
+ rval = /("[^"]+"|\S+)/
30
+ rsearch = /(?:(#{searchables.keys.join('|')}):#{rval})|#{rval}/i
31
+ unless pattern.blank?
32
+ terms = pattern.scan(rsearch).map(&:compact).map do |term|
33
+ term.last.scan(/\b(\S+)\b/).flatten.map do |word|
34
+ /#{term.length > 1 ? Regexp.escape(term.first) : '[^:]+'}:.*#{Regexp.escape(word)}/i
35
+ end
36
+ end.flatten
37
+ all_in(:searchable_values => terms)
38
+ else
39
+ criteria
40
+ end
41
+ end
42
+ end
43
+
44
+ module InstanceMethods
45
+ private
46
+
47
+ def update_searchable_values
48
+ send :searchable_values=, self.searchable_fields.values.map {|metadata| metadata.searchable_value(self)}.flatten
49
+ end
50
+
51
+ def find_searchable_value(name)
52
+ matches = self.searchable_values.grep(/^#{name}:(.*)/){$1}
53
+ matches.count == 1 ? matches.first : matches
54
+ end
55
+ end
56
+
57
+ class Metadata
58
+ attr_accessor :field_name, :field, :association, :options
59
+
60
+ def initialize(attributes = {})
61
+ attributes.each do |key, value|
62
+ send(:"#{key}=", value)
63
+ end
64
+ options[:only] = [options[:only]].flatten.compact
65
+ options[:except] = [options[:except]].flatten.compact
66
+ end
67
+
68
+ def searchable_value(model)
69
+ searchable_names(model).map {|searchable_name, value, sub_name, options| value_for(searchable_name, value, sub_name, options)}
70
+ end
71
+
72
+ def searchable_names(model)
73
+ name = options[:skip_prefix].presence ? nil : (options[:as] || self.field_name)
74
+ value = model.present? ? model.send(self.field_name) : nil
75
+ sub_fields = self.association.class_name.constantize.searchables if self.association
76
+ fields = (sub_fields.keys - options[:except]) & (options[:only].blank? ? sub_fields.keys : options[:only]) if sub_fields
77
+ case self.association.try(:macro)
78
+ when nil
79
+ [[self.field.type == Array ? name.to_s.singularize.to_sym : name, value, nil, self.options]]
80
+ when :embedded_in, :embeds_one, :referenced_in, :references_one
81
+ fields.map {|sub_name| [create_nested_name(name, sub_name), value, sub_name, self.options.merge(sub_fields[sub_name])]}
82
+ else
83
+ fields.map {|sub_name| [create_nested_name(name.to_s.singularize, sub_name.to_s.pluralize), value, sub_name, self.options.merge(sub_fields[sub_name])]}
84
+ end
85
+ end
86
+
87
+ def create_nested_name(owning_name, sub_name)
88
+ [owning_name, sub_name].compact.join("_").to_sym
89
+ end
90
+
91
+ def value_for(searchable_name, value, field_name, options)
92
+ v = field_name.present? && value.present? ? [value].flatten.map{|i| i.send("_#{field_name}")} : value
93
+ v = v.is_a?(Array) ? v.join(" ") : v.to_s
94
+ v = v.gsub(/[[:punct:]]/, '') unless options[:keep_punctuation]
95
+ v.downcase.split.map {|word| [searchable_name, word].join(":")}
96
+ end
97
+ end
98
+
99
+ def self.included(receiver)
100
+ receiver.extend ClassMethods
101
+ receiver.send :include, InstanceMethods
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,3 @@
1
+ module SearchMagic
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,3 @@
1
+ module SearchMagic
2
+ require 'search_magic/full_text_search'
3
+ end
@@ -0,0 +1,26 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "search_magic/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "search_magic"
7
+ s.version = SearchMagic::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Joshua Bowers"]
10
+ s.email = ["joshua.bowers+code@gmail.com"]
11
+ s.homepage = ""
12
+ s.summary = %q{SearchMagic provides scoped full text search and sort capabilities to Mongoid documents}
13
+ s.description = %q{Adds scopes to a Mongoid document providing search and sort capabilities on arbitrary fields and associations.}
14
+
15
+ s.add_dependency("mongoid", ">= 2.0.0.rc.7")
16
+ s.add_development_dependency("rspec")
17
+ s.add_development_dependency("database_cleaner")
18
+ s.add_development_dependency("bson_ext")
19
+
20
+ s.rubyforge_project = "search_magic"
21
+
22
+ s.files = `git ls-files`.split("\n")
23
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
24
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
25
+ s.require_paths = ["lib"]
26
+ end
@@ -0,0 +1,14 @@
1
+ class Address
2
+ include Mongoid::Document
3
+ include SearchMagic::FullTextSearch
4
+ field :street
5
+ field :city
6
+ field :state
7
+ field :post_code
8
+ embedded_in :person
9
+
10
+ search_on :street
11
+ search_on :city
12
+ search_on :state
13
+ search_on :post_code
14
+ end
@@ -0,0 +1,12 @@
1
+ class Asset
2
+ include Mongoid::Document
3
+ include SearchMagic::FullTextSearch
4
+ field :title
5
+ field :description
6
+ field :tags, :type => Array, :default => []
7
+ field :uuid
8
+
9
+ search_on :title
10
+ search_on :description
11
+ search_on :tags, :keep_punctuation => true
12
+ end
@@ -0,0 +1,5 @@
1
+ class NoSearchables
2
+ include Mongoid::Document
3
+ include SearchMagic::FullTextSearch
4
+ field :foo
5
+ end
@@ -0,0 +1,12 @@
1
+ class Part
2
+ include Mongoid::Document
3
+ include SearchMagic::FullTextSearch
4
+ field :serial
5
+ field :status
6
+
7
+ referenced_in :part_number
8
+
9
+ search_on :serial
10
+ search_on :status
11
+ search_on :part_number, :skip_prefix => true
12
+ end
@@ -0,0 +1,9 @@
1
+ class PartCategory
2
+ include Mongoid::Document
3
+ include SearchMagic::FullTextSearch
4
+ field :name
5
+
6
+ references_many :part_numbers
7
+
8
+ search_on :name
9
+ end
@@ -0,0 +1,11 @@
1
+ class PartNumber
2
+ include Mongoid::Document
3
+ include SearchMagic::FullTextSearch
4
+ field :value
5
+
6
+ references_many :parts
7
+ referenced_in :part_category
8
+
9
+ search_on :value, :as => :part_number
10
+ search_on :part_category, :as => :category
11
+ end
@@ -0,0 +1,13 @@
1
+ class Person
2
+ include Mongoid::Document
3
+ include SearchMagic::FullTextSearch
4
+ field :name
5
+ embeds_one :address
6
+ embeds_many :phones
7
+
8
+ accepts_nested_attributes_for :address, :phones
9
+
10
+ search_on :name
11
+ search_on :address
12
+ search_on :phones, :as => :mobile, :only => [:number]
13
+ end
@@ -0,0 +1,10 @@
1
+ class Phone
2
+ include Mongoid::Document
3
+ include SearchMagic::FullTextSearch
4
+ field :country_code, :type => Integer, :default => 1
5
+ field :number
6
+ embedded_in :person
7
+
8
+ search_on :country_code
9
+ search_on :number, :keep_punctuation => true
10
+ end
@@ -0,0 +1,29 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+
4
+ require 'mongoid'
5
+ require 'search_magic'
6
+
7
+ MODELS = File.join(File.dirname(__FILE__), "models")
8
+
9
+ Mongoid.configure do |config|
10
+ name = "search_magic_test"
11
+ config.master = Mongo::Connection.new.db(name)
12
+ config.logger = nil
13
+ end
14
+
15
+ Dir[ File.join(MODELS, "*.rb") ].sort.each {|file| require file}
16
+
17
+ RSpec::configure do |config|
18
+ config.mock_with :rspec
19
+
20
+ require 'database_cleaner'
21
+ config.before(:suite) do
22
+ DatabaseCleaner.strategy = :truncation
23
+ DatabaseCleaner.orm = "mongoid"
24
+ end
25
+
26
+ config.before(:each) do
27
+ DatabaseCleaner.clean
28
+ end
29
+ end
@@ -0,0 +1,128 @@
1
+ require 'spec_helper'
2
+
3
+ describe SearchMagic::FullTextSearch do
4
+ describe "model includes embedded document fields in :searchable_fields" do
5
+ subject { Person }
6
+ it { subject.searchable_fields.keys.should include(:address) }
7
+ it { subject.searchable_fields.keys.should include(:phones) }
8
+ it { should respond_to(:searchables)}
9
+ its(:searchables) { should include(:address_street, :address_city, :address_state, :address_post_code, :mobile_numbers)}
10
+ end
11
+
12
+ describe "model includes referenced document fields in :searchable_fields" do
13
+ subject { Part }
14
+ it { subject.searchable_fields.keys.should include(:part_number) }
15
+ its(:searchables) { should include(:part_number, :category_name) }
16
+ end
17
+
18
+ context "when a model embeds one other document" do
19
+ before(:each) do
20
+ Person.create(:name => "Joshua", :address => {:street => "123 Example St.", :city => "Nowhereland", :state => "CA", :post_code => 12345}, :phones => [{:country_code => 1, :number => "555-1234"}, {:country_code => 2, :number => "333-7890"}])
21
+ Person.create(:name => "Samuel", :address => {:street => "4010 Arbitrary Ave.", :city => "New Somewhere", :state => "MO", :post_code => 54321}, :phones => [{:country_code => 5, :number => "444-4321"}, {:country_code => 1, :number => "555-0987"}])
22
+ end
23
+
24
+ describe "model should have embedded document fields in :searchable_values" do
25
+ subject { Person.where(:name => "Joshua").first }
26
+ its(:address) { should_not be_nil }
27
+ its(:phones) { should_not be_empty }
28
+ its(:searchable_values) { should include("address_street:123", "address_street:example", "address_street:st") }
29
+ it { should respond_to(:_address_street, :_address_city, :_address_state, :_address_post_code, :_mobile_numbers)}
30
+ its(:_address_street) { should include("123", "example", "st") }
31
+ its(:_address_state) { should == "ca" }
32
+ its(:_address_city) { should == "nowhereland" }
33
+ its(:_address_post_code) { should == "12345" }
34
+ its(:_mobile_numbers) { should include("555-1234", "333-7890") }
35
+ end
36
+
37
+ context "when searching for 'address_city:nowhereland'" do
38
+ subject { Person.search("address_city:nowhereland") }
39
+ its("selector.keys") { should include(:searchable_values) }
40
+ its(:count) { should == 1 }
41
+ its("first.name") { should == "Joshua" }
42
+ end
43
+
44
+ context "when searching for 'arbitrary address_post_code:54321'" do
45
+ subject { Person.search("arbitrary address_post_code:54321") }
46
+ its(:count) { should == 1 }
47
+ its("first.name") { should == "Samuel" }
48
+ end
49
+ end
50
+
51
+ context "when a model references other documents" do
52
+ before(:each) do
53
+ PartCategory.create(:name => "Table").tap do |category|
54
+ category.part_numbers.create(:value => "T11001").tap do |number|
55
+ number.parts.create(:status => "available", :serial => "T0411001")
56
+ number.parts.create(:status => "broken", :serial => "T0511010")
57
+ end
58
+ category.part_numbers.create(:value => "T11002").tap do |number|
59
+ number.parts.create(:status => "available", :serial => "T0411037")
60
+ number.parts.create(:status => "broken", :serial => "T0511178")
61
+ end
62
+ end
63
+ PartCategory.create(:name => "Chair").tap do |category|
64
+ category.part_numbers.create(:value => "C11001").tap do |number|
65
+ number.parts.create(:status => "available", :serial => "C0411001")
66
+ number.parts.create(:status => "broken", :serial => "C0511010")
67
+ end
68
+ category.part_numbers.create(:value => "C11002").tap do |number|
69
+ number.parts.create(:status => "available", :serial => "C0411001")
70
+ number.parts.create(:status => "broken", :serial => "C0511010")
71
+ end
72
+ end
73
+ end
74
+
75
+ context "when a model directly references another document" do
76
+ describe "model should have referenced document fields in :searchable_values" do
77
+ subject { PartNumber.where(:value => "T11001").first }
78
+ it { should be }
79
+ its(:part_category) { should_not be_nil }
80
+ its(:searchable_values) { should include("part_number:t11001", "category_name:table") }
81
+ end
82
+
83
+ context "when searching for 'category_name:table'" do
84
+ subject { PartNumber.search("category_name:table").map(&:value) }
85
+ its(:count) { should == 2 }
86
+ it { should include("T11001", "T11002") }
87
+ end
88
+
89
+ context "when searching for '11001'" do
90
+ subject { PartNumber.search("11001").map(&:value) }
91
+ its(:count) { should == 2 }
92
+ it { should include("T11001", "C11001") }
93
+ end
94
+ end
95
+
96
+ context "when a model references a document which references another document" do
97
+ describe "model should have :searchables from the indirect reference" do
98
+ subject { Part.where(:serial => "T0411001").first }
99
+ it { Part.count.should == 8 }
100
+ it { should be }
101
+ its(:part_number) { should_not be_nil }
102
+ its(:searchable_values) { should include("part_number:t11001", "category_name:table", "status:available", "serial:t0411001") }
103
+ its(:_part_number) { should == "t11001" }
104
+ its(:_category_name) { should == "table" }
105
+ its(:_status) { should == "available" }
106
+ its(:_serial) { should == "T0411001" }
107
+ end
108
+
109
+ context "when searching for 'category_name:table'" do
110
+ subject { Part.search("category_name:table").map(&:serial) }
111
+ its(:count) { should == 4 }
112
+ it { should include("T0411001", "T0511010", "T0411037", "T0511178") }
113
+ end
114
+
115
+ context "when searching for 'broken chair'" do
116
+ subject { Part.search("broken chair").map(&:serial) }
117
+ its(:count) { should == 2 }
118
+ it { should include("C0511010", "C0511010") }
119
+ end
120
+
121
+ context "when searching for 'part_number:T11001'" do
122
+ subject { Part.search("part_number:T11001").map(&:serial) }
123
+ its(:count) { should == 2 }
124
+ it { should include("T0411001", "T0511010") }
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,108 @@
1
+ require 'spec_helper'
2
+
3
+ describe SearchMagic::FullTextSearch do
4
+ context "when included in a model without :searchables" do
5
+ subject { NoSearchables }
6
+
7
+ it { should respond_to(:search_on).with(2).arguments }
8
+
9
+ it { should respond_to(:searchable_fields) }
10
+ its(:searchable_fields) { should be_a(Hash) }
11
+ its(:searchable_fields) { should be_blank }
12
+
13
+ its("fields.keys") { should include("searchable_values") }
14
+ describe "searchable_values" do
15
+ subject { NoSearchables.fields["searchable_values"] }
16
+ its(:type) { should == Array }
17
+ its(:default) { should == [] }
18
+ end
19
+
20
+ it { should respond_to(:search).with(1).argument }
21
+ end
22
+
23
+ context "when :search_on called with [:title, :description, :tags]" do
24
+ subject { Asset }
25
+ its("searchable_fields.keys") { should include(:title, :description, :tags) }
26
+ its("searchable_fields.keys") { should_not include(:uuid) }
27
+
28
+ its(:searchables) { should include(:title, :description, :tag) }
29
+ its(:searchables) { should_not include(:uuid) }
30
+ end
31
+
32
+ describe "saving a model should run the :update_searchable_values callback" do
33
+ subject { Asset.new }
34
+ after(:each) { subject.save }
35
+ it { subject.should_receive :update_searchable_values }
36
+ end
37
+
38
+ context "when a model is saved, its :_searchable_values update" do
39
+ subject { Asset.new(:title => "Foo Bar: The Bazzening", :description => "Sequel to last years hit summer blockbuster.", :tags => ["movies", "foo.bar", "the-bazzening"], :uuid => "ae9d14ee-be93-11df-9fec-78ca39fffe11")}
40
+ before(:each) { subject.save }
41
+ its(:searchable_values) { should_not be_empty }
42
+ its(:searchable_values) { should include("title:foo", "title:bar", "title:the", "title:bazzening")}
43
+ its(:searchable_values) { should include("description:sequel", "description:to", "description:last", "description:years", "description:hit", "description:summer", "description:blockbuster")}
44
+ its(:searchable_values) { should_not include("uuid:ae9d14ee-be93-11df-9fec-78ca39fffe11", "uuid:ae9d14ee")}
45
+ its(:searchable_values) { should include("tag:movies", "tag:foo.bar", "tag:the-bazzening")}
46
+ end
47
+
48
+ context "when :search is performed on a model without :searchables" do
49
+ subject { NoSearchables.search("foo") }
50
+ it { should be_a(Mongoid::Criteria) }
51
+ its(:count) { should == 0 }
52
+ end
53
+
54
+ context "when :search is performed on a model with :searchables" do
55
+ before(:each) do
56
+ Asset.create(:title => "Foo Bar: The Bazzening", :description => "Sequel to last years hit summer blockbuster.", :tags => ["movies", "foo.bar", "the-bazzening"])
57
+ Asset.create(:title => "Undercover Foo", :description => "When a foo goes undercover, how far will he go to protect those he loves?", :tags => ["undercover.foo", "action"])
58
+ Asset.create(:title => "Cheese of the Damned", :description => "This is not your father's munster.", :tags => ["movies", "cheese", "munster", "horror"])
59
+ end
60
+
61
+ context "when searching for nil" do
62
+ subject { Asset.search(nil) }
63
+ it { should be_a(Mongoid::Criteria) }
64
+ its("selector.keys") { should_not include(:searchable_values) }
65
+ end
66
+
67
+ context "when searching on an empty string" do
68
+ subject { Asset.search("") }
69
+ it { should be_a(Mongoid::Criteria) }
70
+ its("selector.keys") { should_not include(:searchable_values) }
71
+ end
72
+
73
+ context "when searching for anything" do
74
+ subject { Asset.search("foo") }
75
+ it { should be_a(Mongoid::Criteria) }
76
+ its("selector.keys") { should include(:searchable_values) }
77
+ end
78
+
79
+ context "when searching for 'foo'" do
80
+ subject { Asset.search("foo").map(&:title) }
81
+ its(:count) { should == 2 }
82
+ it { should include("Foo Bar: The Bazzening", "Undercover Foo") }
83
+ end
84
+
85
+ context "when searching for 'title:foo'" do
86
+ subject { Asset.search("title:foo").map(&:title) }
87
+ its(:count) { should == 2 }
88
+ it { should include("Foo Bar: The Bazzening", "Undercover Foo") }
89
+ end
90
+
91
+ context "when searching for 'description:bazzening'" do
92
+ subject { Asset.search("description:bazzening") }
93
+ its(:count) { should == 0 }
94
+ end
95
+
96
+ context "when searching for 'tag:foo.bar'" do
97
+ subject { Asset.search("tag:foo.bar").map(&:title) }
98
+ its(:count) { should == 1 }
99
+ its(:first) { should == "Foo Bar: The Bazzening" }
100
+ end
101
+
102
+ context "when searching for 'tag:movies cheese" do
103
+ subject { Asset.search("tag:movies cheese").map(&:title) }
104
+ its(:count) { should == 1 }
105
+ its(:first) { should == "Cheese of the Damned" }
106
+ end
107
+ end
108
+ end
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: search_magic
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 0
8
+ - 1
9
+ version: 0.0.1
10
+ platform: ruby
11
+ authors:
12
+ - Joshua Bowers
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2011-03-20 00:00:00 -07:00
18
+ default_executable:
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: mongoid
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ none: false
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ segments:
29
+ - 2
30
+ - 0
31
+ - 0
32
+ - rc
33
+ - 7
34
+ version: 2.0.0.rc.7
35
+ type: :runtime
36
+ version_requirements: *id001
37
+ - !ruby/object:Gem::Dependency
38
+ name: rspec
39
+ prerelease: false
40
+ requirement: &id002 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ segments:
46
+ - 0
47
+ version: "0"
48
+ type: :development
49
+ version_requirements: *id002
50
+ - !ruby/object:Gem::Dependency
51
+ name: database_cleaner
52
+ prerelease: false
53
+ requirement: &id003 !ruby/object:Gem::Requirement
54
+ none: false
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ segments:
59
+ - 0
60
+ version: "0"
61
+ type: :development
62
+ version_requirements: *id003
63
+ - !ruby/object:Gem::Dependency
64
+ name: bson_ext
65
+ prerelease: false
66
+ requirement: &id004 !ruby/object:Gem::Requirement
67
+ none: false
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ segments:
72
+ - 0
73
+ version: "0"
74
+ type: :development
75
+ version_requirements: *id004
76
+ description: Adds scopes to a Mongoid document providing search and sort capabilities on arbitrary fields and associations.
77
+ email:
78
+ - joshua.bowers+code@gmail.com
79
+ executables: []
80
+
81
+ extensions: []
82
+
83
+ extra_rdoc_files: []
84
+
85
+ files:
86
+ - .gitignore
87
+ - Gemfile
88
+ - Gemfile.lock
89
+ - README.textile
90
+ - Rakefile
91
+ - lib/search_magic.rb
92
+ - lib/search_magic/full_text_search.rb
93
+ - lib/search_magic/version.rb
94
+ - search_magic.gemspec
95
+ - spec/models/address.rb
96
+ - spec/models/asset.rb
97
+ - spec/models/no_searchables.rb
98
+ - spec/models/part.rb
99
+ - spec/models/part_category.rb
100
+ - spec/models/part_number.rb
101
+ - spec/models/person.rb
102
+ - spec/models/phone.rb
103
+ - spec/spec_helper.rb
104
+ - spec/unit/search_magic/associations_spec.rb
105
+ - spec/unit/search_magic/fields_spec.rb
106
+ has_rdoc: true
107
+ homepage: ""
108
+ licenses: []
109
+
110
+ post_install_message:
111
+ rdoc_options: []
112
+
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ none: false
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ segments:
121
+ - 0
122
+ version: "0"
123
+ required_rubygems_version: !ruby/object:Gem::Requirement
124
+ none: false
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ segments:
129
+ - 0
130
+ version: "0"
131
+ requirements: []
132
+
133
+ rubyforge_project: search_magic
134
+ rubygems_version: 1.3.7
135
+ signing_key:
136
+ specification_version: 3
137
+ summary: SearchMagic provides scoped full text search and sort capabilities to Mongoid documents
138
+ test_files:
139
+ - spec/models/address.rb
140
+ - spec/models/asset.rb
141
+ - spec/models/no_searchables.rb
142
+ - spec/models/part.rb
143
+ - spec/models/part_category.rb
144
+ - spec/models/part_number.rb
145
+ - spec/models/person.rb
146
+ - spec/models/phone.rb
147
+ - spec/spec_helper.rb
148
+ - spec/unit/search_magic/associations_spec.rb
149
+ - spec/unit/search_magic/fields_spec.rb