search_magic 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +47 -0
- data/README.textile +129 -0
- data/Rakefile +2 -0
- data/lib/search_magic/full_text_search.rb +104 -0
- data/lib/search_magic/version.rb +3 -0
- data/lib/search_magic.rb +3 -0
- data/search_magic.gemspec +26 -0
- data/spec/models/address.rb +14 -0
- data/spec/models/asset.rb +12 -0
- data/spec/models/no_searchables.rb +5 -0
- data/spec/models/part.rb +12 -0
- data/spec/models/part_category.rb +9 -0
- data/spec/models/part_number.rb +11 -0
- data/spec/models/person.rb +13 -0
- data/spec/models/phone.rb +10 -0
- data/spec/spec_helper.rb +29 -0
- data/spec/unit/search_magic/associations_spec.rb +128 -0
- data/spec/unit/search_magic/fields_spec.rb +108 -0
- metadata +149 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
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,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
|
data/lib/search_magic.rb
ADDED
@@ -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
|
data/spec/models/part.rb
ADDED
@@ -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
|
data/spec/spec_helper.rb
ADDED
@@ -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
|