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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +135 -0
  4. data/Rakefile +34 -0
  5. data/lib/plain_search.rb +6 -0
  6. data/lib/plain_search/concern.rb +186 -0
  7. data/lib/plain_search/railtie.rb +13 -0
  8. data/lib/plain_search/version.rb +3 -0
  9. data/lib/tasks/plain_search.rake +19 -0
  10. data/test/dummy/README.rdoc +28 -0
  11. data/test/dummy/Rakefile +6 -0
  12. data/test/dummy/app/assets/javascripts/application.js +13 -0
  13. data/test/dummy/app/assets/stylesheets/application.css +15 -0
  14. data/test/dummy/app/controllers/application_controller.rb +5 -0
  15. data/test/dummy/app/helpers/application_helper.rb +2 -0
  16. data/test/dummy/app/models/company.rb +3 -0
  17. data/test/dummy/app/models/dossier.rb +2 -0
  18. data/test/dummy/app/models/employee.rb +3 -0
  19. data/test/dummy/app/models/search_term.rb +3 -0
  20. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  21. data/test/dummy/bin/bundle +3 -0
  22. data/test/dummy/bin/rails +4 -0
  23. data/test/dummy/bin/rake +4 -0
  24. data/test/dummy/bin/setup +29 -0
  25. data/test/dummy/config.ru +4 -0
  26. data/test/dummy/config/application.rb +26 -0
  27. data/test/dummy/config/boot.rb +5 -0
  28. data/test/dummy/config/database.yml +25 -0
  29. data/test/dummy/config/environment.rb +5 -0
  30. data/test/dummy/config/environments/development.rb +41 -0
  31. data/test/dummy/config/environments/production.rb +79 -0
  32. data/test/dummy/config/environments/test.rb +42 -0
  33. data/test/dummy/config/initializers/assets.rb +11 -0
  34. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  35. data/test/dummy/config/initializers/cookies_serializer.rb +3 -0
  36. data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  37. data/test/dummy/config/initializers/inflections.rb +16 -0
  38. data/test/dummy/config/initializers/mime_types.rb +4 -0
  39. data/test/dummy/config/initializers/session_store.rb +3 -0
  40. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  41. data/test/dummy/config/locales/en.yml +23 -0
  42. data/test/dummy/config/routes.rb +56 -0
  43. data/test/dummy/config/secrets.yml +22 -0
  44. data/test/dummy/db/development.sqlite3 +0 -0
  45. data/test/dummy/db/migrate/20151013022315_create_base_tables.rb +32 -0
  46. data/test/dummy/db/schema.rb +47 -0
  47. data/test/dummy/db/test.sqlite3 +0 -0
  48. data/test/dummy/log/development.log +340 -0
  49. data/test/dummy/log/test.log +3615 -0
  50. data/test/dummy/public/404.html +67 -0
  51. data/test/dummy/public/422.html +67 -0
  52. data/test/dummy/public/500.html +66 -0
  53. data/test/dummy/public/favicon.ico +0 -0
  54. data/test/plain_search_test.rb +136 -0
  55. data/test/test_helper.rb +19 -0
  56. 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
@@ -0,0 +1,6 @@
1
+ require 'plain_search/concern'
2
+ require 'plain_search/railtie'
3
+
4
+ module PlainSearch
5
+
6
+ end
@@ -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,13 @@
1
+ module PlainSearch
2
+ class Railtie < Rails::Railtie
3
+
4
+ initializer 'plain_search.extend_active_record_base' do
5
+ ActiveRecord::Base.send(:include, PlainSearch)
6
+ end
7
+
8
+ rake_tasks do
9
+ load "tasks/plain_search.rake"
10
+ end
11
+
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module PlainSearch
2
+ VERSION = "0.1.0"
3
+ 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>.