plain_search 0.1.0
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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +135 -0
- data/Rakefile +34 -0
- data/lib/plain_search.rb +6 -0
- data/lib/plain_search/concern.rb +186 -0
- data/lib/plain_search/railtie.rb +13 -0
- data/lib/plain_search/version.rb +3 -0
- data/lib/tasks/plain_search.rake +19 -0
- data/test/dummy/README.rdoc +28 -0
- data/test/dummy/Rakefile +6 -0
- data/test/dummy/app/assets/javascripts/application.js +13 -0
- data/test/dummy/app/assets/stylesheets/application.css +15 -0
- data/test/dummy/app/controllers/application_controller.rb +5 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/models/company.rb +3 -0
- data/test/dummy/app/models/dossier.rb +2 -0
- data/test/dummy/app/models/employee.rb +3 -0
- data/test/dummy/app/models/search_term.rb +3 -0
- data/test/dummy/app/views/layouts/application.html.erb +14 -0
- data/test/dummy/bin/bundle +3 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +29 -0
- data/test/dummy/config.ru +4 -0
- data/test/dummy/config/application.rb +26 -0
- data/test/dummy/config/boot.rb +5 -0
- data/test/dummy/config/database.yml +25 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +41 -0
- data/test/dummy/config/environments/production.rb +79 -0
- data/test/dummy/config/environments/test.rb +42 -0
- data/test/dummy/config/initializers/assets.rb +11 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/test/dummy/config/initializers/cookies_serializer.rb +3 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
- data/test/dummy/config/initializers/inflections.rb +16 -0
- data/test/dummy/config/initializers/mime_types.rb +4 -0
- data/test/dummy/config/initializers/session_store.rb +3 -0
- data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/test/dummy/config/locales/en.yml +23 -0
- data/test/dummy/config/routes.rb +56 -0
- data/test/dummy/config/secrets.yml +22 -0
- data/test/dummy/db/development.sqlite3 +0 -0
- data/test/dummy/db/migrate/20151013022315_create_base_tables.rb +32 -0
- data/test/dummy/db/schema.rb +47 -0
- data/test/dummy/db/test.sqlite3 +0 -0
- data/test/dummy/log/development.log +340 -0
- data/test/dummy/log/test.log +3615 -0
- data/test/dummy/public/404.html +67 -0
- data/test/dummy/public/422.html +67 -0
- data/test/dummy/public/500.html +66 -0
- data/test/dummy/public/favicon.ico +0 -0
- data/test/plain_search_test.rb +136 -0
- data/test/test_helper.rb +19 -0
- metadata +176 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: d135d34007cb8f01bbdbffc6604b9b8072da3f9d
|
4
|
+
data.tar.gz: c40f5ef7ecd3213c0cfdf71ecc681f7ef9ca1b82
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: cf154c6bc924dc477692edf5991917e4b0e6c765271ac8f5120cbeb5c27d6748f1cd9165daf476e63130d5add4509c62396a01abb8b6010846278a5d9f923fbe
|
7
|
+
data.tar.gz: 00e710e1e19548da9f711b0cb5b6c2c25334078b9d119a7ec31634fc6678475b87d64fbd2e01a8b505c142f6ea1a98eb75b353e92c3866c5cd32fed4bc7a8d01
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2015 Andreas Baumgart (https://baumgart.software)
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,135 @@
|
|
1
|
+
PlainSearch
|
2
|
+
===========
|
3
|
+
|
4
|
+
PlainSearch is a dead-simple, scored search plugin for single ActiveRecord
|
5
|
+
models. Suited for small projects with little needs for scalability
|
6
|
+
and a reserved attitude towards technical debt (i.e. ElasticSearch, Solr, ...).
|
7
|
+
|
8
|
+
Quick Start
|
9
|
+
-----------
|
10
|
+
|
11
|
+
### Setup
|
12
|
+
|
13
|
+
Add the Gem to your Gemfile:
|
14
|
+
|
15
|
+
gem 'plain_search'
|
16
|
+
|
17
|
+
Run `bundle install` to install it.
|
18
|
+
|
19
|
+
You'll need a table for caching the search terms. Run the generator for
|
20
|
+
a new migration:
|
21
|
+
|
22
|
+
rails g migration CreateSearchTerms
|
23
|
+
|
24
|
+
Put this into the migration file:
|
25
|
+
|
26
|
+
class CreateSearchTerms < ActiveRecord::Migration
|
27
|
+
def change
|
28
|
+
create_table :search_terms do |t|
|
29
|
+
t.string :term, index: true
|
30
|
+
t.string :source, index: true
|
31
|
+
t.belongs_to :findable, polymorphic: true
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
Apply the changes by running `rake db:migrate`.
|
37
|
+
|
38
|
+
At the moment the Gem doesn't provide the AR model for search terms (feel free
|
39
|
+
to add it - pull requests appreciated).
|
40
|
+
|
41
|
+
So you'll have to add it to you model. Create `app/model/search_term.rb` with
|
42
|
+
this content:
|
43
|
+
|
44
|
+
class SearchTerm < ActiveRecord::Base
|
45
|
+
belongs_to :findable, polymorphic: true
|
46
|
+
end
|
47
|
+
|
48
|
+
Having, for instance, a model `Employee` which has the attributes `first_name`,
|
49
|
+
`last_name`, `profession` and `address`, you can enable ranked searches like so:
|
50
|
+
|
51
|
+
class Employee < ActiveRecord::Base
|
52
|
+
searchable_by id: 100, first_name: 10, last_name: 10, profession: 5, address: 1
|
53
|
+
# ...
|
54
|
+
end
|
55
|
+
|
56
|
+
`searchable_by` receives a hash as only argument. Its keys determine the
|
57
|
+
attributes to search. The hash's values determine the value contributed to the
|
58
|
+
rank for a single match. In short: The higher the value, the higher the rank.
|
59
|
+
See #Ranking for details.
|
60
|
+
|
61
|
+
### Performing searches
|
62
|
+
|
63
|
+
Now, with all the setup done, performing a search is pretty straight-forward:
|
64
|
+
|
65
|
+
matches = Employee.search('susi sorglos 33602 hauptstrasse developer')
|
66
|
+
|
67
|
+
`matches` contains a list of `Employee`s, ordered by the search score, which is
|
68
|
+
also available as an attribute (e.q. `matches[0].score`).
|
69
|
+
|
70
|
+
### Rebuilding search terms
|
71
|
+
|
72
|
+
In the background, whenever you create or update a model on which
|
73
|
+
`searchable_by` was called, the searchable fields' contents will be cached in the
|
74
|
+
`search_terms` table. This means pre-existent records won't appear in the
|
75
|
+
search results because they have never hit the respective post-save callback.
|
76
|
+
You'll have to rebuild the cache for this models manually using
|
77
|
+
`#rebuild_search_terms_for_all` like so:
|
78
|
+
|
79
|
+
Employee.rebuild_search_terms_for_all
|
80
|
+
|
81
|
+
### Performing updates without caching search terms
|
82
|
+
|
83
|
+
Let's say you have a scenario which performs a lot of updates to a specific
|
84
|
+
model. Every insertion or update would result in `SearchTerm`s being build for
|
85
|
+
the respective record. This can be very time consuming (keep in mind that
|
86
|
+
PlainSearch is not a high performance beast, but a mere solution for
|
87
|
+
prototyping).
|
88
|
+
|
89
|
+
To circumvent this you can call the insertions/updates inside a block passed
|
90
|
+
to `#without_search_term_updates`. Which simply suppresses the after_save
|
91
|
+
callback which normally builds SearchTerms.
|
92
|
+
|
93
|
+
An example:
|
94
|
+
|
95
|
+
Employee.without_search_term_updates do
|
96
|
+
Employee.update_all({some_non_searchable_attribute: 42})
|
97
|
+
end
|
98
|
+
|
99
|
+
Therefore changes to searchable attributes within won't be reflected in the search
|
100
|
+
results. So you should make sure that you either rebuild the search terms
|
101
|
+
afterwards (e.q. using ActiveJob) or make sure no searchable attributes have
|
102
|
+
been touched in the operation.
|
103
|
+
|
104
|
+
Alternatively you could set `Model#auto_update_search_terms` to `true`/`false`,
|
105
|
+
which is basically what `#without_search_term_updates` does, but in a less
|
106
|
+
error-prone manner.
|
107
|
+
|
108
|
+
### Search term delimiter
|
109
|
+
|
110
|
+
Values of searchable attributes are split into search terms using a regular
|
111
|
+
expression. This is `/[^\w\u00C0-\u00ff]/` by default. You can adjust it
|
112
|
+
to fit your specific needs:
|
113
|
+
|
114
|
+
class Employee < ActiveRecord::Base
|
115
|
+
searchable_by # ...
|
116
|
+
search_terms_delimiter = /[\-.]/
|
117
|
+
# ...
|
118
|
+
end
|
119
|
+
|
120
|
+
Ranking
|
121
|
+
-------
|
122
|
+
|
123
|
+
Plain search facilitates a very simple ranking algorithm.
|
124
|
+
Every attribute considered for search has a score assigned to it. This is the
|
125
|
+
score of a single match. The total score of a search result is the sum of the
|
126
|
+
score of all matches.
|
127
|
+
|
128
|
+
Here's an example: If we had an `Employee` with `first_name` being "Susi" and
|
129
|
+
her `profession` being "Web developer", then (in the scenario above), a query
|
130
|
+
for "susi web" would result in a total score of 15 for this record. That is 10
|
131
|
+
for the matching first name and 5 for the matching profession.
|
132
|
+
|
133
|
+
License
|
134
|
+
-------
|
135
|
+
See [MIT-LICENSE](MIT-LICENSE)
|
data/Rakefile
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'rdoc/task'
|
8
|
+
|
9
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
10
|
+
rdoc.rdoc_dir = 'rdoc'
|
11
|
+
rdoc.title = 'PlainSearch'
|
12
|
+
rdoc.options << '--line-numbers'
|
13
|
+
rdoc.rdoc_files.include('README.rdoc')
|
14
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
15
|
+
end
|
16
|
+
|
17
|
+
|
18
|
+
|
19
|
+
|
20
|
+
|
21
|
+
|
22
|
+
Bundler::GemHelper.install_tasks
|
23
|
+
|
24
|
+
require 'rake/testtask'
|
25
|
+
|
26
|
+
Rake::TestTask.new(:test) do |t|
|
27
|
+
t.libs << 'lib'
|
28
|
+
t.libs << 'test'
|
29
|
+
t.pattern = 'test/**/*_test.rb'
|
30
|
+
t.verbose = false
|
31
|
+
end
|
32
|
+
|
33
|
+
|
34
|
+
task default: :test
|
data/lib/plain_search.rb
ADDED
@@ -0,0 +1,186 @@
|
|
1
|
+
module PlainSearch
|
2
|
+
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
|
7
|
+
def clear_search_terms_for(attributes)
|
8
|
+
search_terms.where(source: attributes).delete_all
|
9
|
+
end
|
10
|
+
|
11
|
+
def changed_searchable_attributes
|
12
|
+
self.class.searchable_attributes.select do |attribute_name|
|
13
|
+
if has_attribute? attribute_name
|
14
|
+
attribute_changed? attribute_name
|
15
|
+
else
|
16
|
+
true # not all attributes map to DB columns and support *_changed?
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def rebuild_search_terms
|
22
|
+
self.class.transaction do
|
23
|
+
clear_search_terms_for(self.class.searchable_attributes)
|
24
|
+
create_search_terms_for(self.class.searchable_attributes)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def create_search_terms_for(attributes)
|
29
|
+
self.class.transaction do
|
30
|
+
Array.wrap(attributes).each do |attr_name|
|
31
|
+
attr_value = send(attr_name)
|
32
|
+
self.class.tokenize_search_terms(attr_value).each do |term|
|
33
|
+
SearchTerm.create!(findable: self, term: term, source: attr_name)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
|
41
|
+
class_methods do
|
42
|
+
|
43
|
+
def auto_update_search_terms?
|
44
|
+
!!auto_update_search_terms
|
45
|
+
end
|
46
|
+
|
47
|
+
def searchable?
|
48
|
+
searchable_attributes.present? and searchable_attributes.any?
|
49
|
+
end
|
50
|
+
|
51
|
+
def without_search_term_updates
|
52
|
+
orig_value = self.auto_update_search_terms
|
53
|
+
begin
|
54
|
+
self.auto_update_search_terms = false
|
55
|
+
yield if block_given?
|
56
|
+
ensure
|
57
|
+
self.auto_update_search_terms = orig_value
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def searchable_by(*attributes)
|
62
|
+
|
63
|
+
has_many :search_terms, as: :findable
|
64
|
+
|
65
|
+
cattr_accessor :searchable_attributes
|
66
|
+
cattr_accessor :searchable_attribute_scores
|
67
|
+
|
68
|
+
cattr_accessor :auto_update_search_terms
|
69
|
+
self.auto_update_search_terms = true
|
70
|
+
|
71
|
+
cattr_accessor :search_terms_delimiter
|
72
|
+
|
73
|
+
# Add latin-1 umlauts to word characters.
|
74
|
+
# Add more if you must.
|
75
|
+
self.search_terms_delimiter = /[^\w\u00C0-\u00ff]/
|
76
|
+
|
77
|
+
delegate :searchable?, to: :class
|
78
|
+
delegate :auto_update_search_terms, to: :class
|
79
|
+
delegate :auto_update_search_terms?, to: :class
|
80
|
+
|
81
|
+
if attributes.size == 1 and attributes[0].is_a? Hash
|
82
|
+
# searchable_by attr1: 5, attr2: 1, ...
|
83
|
+
self.searchable_attribute_scores = attributes[0]
|
84
|
+
self.searchable_attributes = attributes[0].keys
|
85
|
+
else
|
86
|
+
# searchable_by :attr1, :attr2, ...
|
87
|
+
self.searchable_attribute_scores = nil
|
88
|
+
self.searchable_attributes = attributes
|
89
|
+
end
|
90
|
+
|
91
|
+
after_save do
|
92
|
+
if searchable? and auto_update_search_terms?
|
93
|
+
attribs = changed_searchable_attributes
|
94
|
+
clear_search_terms_for(attribs)
|
95
|
+
create_search_terms_for(attribs)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
100
|
+
|
101
|
+
def tokenize_search_terms(value)
|
102
|
+
value.to_s.gsub(search_terms_delimiter, ' ').gsub(/\s+/, ' ').split(' ')
|
103
|
+
end
|
104
|
+
|
105
|
+
def rebuild_search_terms_for_all
|
106
|
+
self.transaction do
|
107
|
+
SearchTerm.where("findable_type = '#{self.name}'").delete_all
|
108
|
+
all.each do |record|
|
109
|
+
record.create_search_terms_for(self.searchable_attributes)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def search(query)
|
115
|
+
|
116
|
+
return none if searchable_attributes.empty?
|
117
|
+
|
118
|
+
terms = tokenize_search_terms(query)
|
119
|
+
return none if terms.empty?
|
120
|
+
|
121
|
+
if score_search?
|
122
|
+
perform_scored_search(terms)
|
123
|
+
else
|
124
|
+
perform_unscored_search(terms)
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|
128
|
+
|
129
|
+
def score_search?
|
130
|
+
not searchable_attribute_scores.nil?
|
131
|
+
end
|
132
|
+
|
133
|
+
private
|
134
|
+
|
135
|
+
def search_conditions_for_terms(terms)
|
136
|
+
quoted_terms = terms.collect do |t|
|
137
|
+
ActiveRecord::Base.connection.quote("#{t}%")
|
138
|
+
end
|
139
|
+
quoted_terms.collect do |quoted_term|
|
140
|
+
"#{SearchTerm.table_name}.term LIKE #{quoted_term}"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def perform_scored_search(terms)
|
145
|
+
conditions = search_conditions_for_terms(terms)
|
146
|
+
|
147
|
+
score_statement = searchable_attributes.collect do |attr_name|
|
148
|
+
"WHEN search_terms.source = '#{attr_name}' then #{searchable_attribute_scores[attr_name]}"
|
149
|
+
end.join("\n ")
|
150
|
+
|
151
|
+
find_by_sql <<-SQL
|
152
|
+
SELECT SUM(e1.match_score) AS score, e1.*
|
153
|
+
FROM (
|
154
|
+
SELECT
|
155
|
+
CASE
|
156
|
+
#{score_statement}
|
157
|
+
ELSE 0
|
158
|
+
END AS match_score,
|
159
|
+
#{table_name}.*
|
160
|
+
FROM #{table_name}
|
161
|
+
JOIN #{SearchTerm.table_name}
|
162
|
+
ON #{SearchTerm.table_name}.findable_id = #{table_name}.id
|
163
|
+
AND #{SearchTerm.table_name}.findable_type = '#{self.name}'
|
164
|
+
WHERE #{conditions.join(' OR ')}
|
165
|
+
) AS e1
|
166
|
+
GROUP BY e1.id
|
167
|
+
ORDER BY score DESC
|
168
|
+
SQL
|
169
|
+
end
|
170
|
+
|
171
|
+
def perform_unscored_search(terms)
|
172
|
+
conditions = search_conditions_for_terms(terms)
|
173
|
+
find_by_sql <<-SQL
|
174
|
+
SELECT #{table_name}.*, COUNT(#{SearchTerm.table_name}.id) as hits
|
175
|
+
FROM #{table_name}
|
176
|
+
JOIN #{SearchTerm.table_name}
|
177
|
+
ON #{SearchTerm.table_name}.findable_id = #{table_name}.id
|
178
|
+
AND #{SearchTerm.table_name}.findable_type = '#{self.name}'
|
179
|
+
WHERE #{conditions.join(' OR ')}
|
180
|
+
GROUP BY #{table_name}.id
|
181
|
+
ORDER BY hits DESC
|
182
|
+
SQL
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
namespace :plain_search do
|
2
|
+
|
3
|
+
desc "Clear and rebuild search terms for a given model class"
|
4
|
+
task rebuild: :environment do
|
5
|
+
|
6
|
+
class_name = ENV['CLASS']
|
7
|
+
|
8
|
+
abort('Usage: rake plain_search:rebuild_terms CLASS=MySearchableModel') if class_name.blank?
|
9
|
+
|
10
|
+
klass = Object.const_get(class_name)
|
11
|
+
|
12
|
+
raise "Plain Search is not enabled on model #{class_name}" unless klass.searchable?
|
13
|
+
|
14
|
+
puts "Rebuilding search terms for model #{class_name}..."
|
15
|
+
klass.rebuild_search_terms_for_all
|
16
|
+
puts "Done."
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
== README
|
2
|
+
|
3
|
+
This README would normally document whatever steps are necessary to get the
|
4
|
+
application up and running.
|
5
|
+
|
6
|
+
Things you may want to cover:
|
7
|
+
|
8
|
+
* Ruby version
|
9
|
+
|
10
|
+
* System dependencies
|
11
|
+
|
12
|
+
* Configuration
|
13
|
+
|
14
|
+
* Database creation
|
15
|
+
|
16
|
+
* Database initialization
|
17
|
+
|
18
|
+
* How to run the test suite
|
19
|
+
|
20
|
+
* Services (job queues, cache servers, search engines, etc.)
|
21
|
+
|
22
|
+
* Deployment instructions
|
23
|
+
|
24
|
+
* ...
|
25
|
+
|
26
|
+
|
27
|
+
Please feel free to use a different markup language if you do not plan to run
|
28
|
+
<tt>rake doc:app</tt>.
|