acts_as_fulltextable 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,18 @@
1
+ *.gem
2
+ *.rbc
3
+ *.DS_Store
4
+ .bundle
5
+ .config
6
+ .yardoc
7
+ Gemfile.lock
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in acts_as_fulltextable.gemspec
4
+ rails_version = '~> 3.1'
5
+
6
+ #gem 'actionpack', rails_version
7
+ gem 'activerecord', rails_version
8
+
9
+ gem 'rake', '~> 0.8.7'
10
+ #gem 'mocha', '0.9.7'
11
+ #gem 'sqlite3-ruby', '1.3.1'
12
+ gem 'mysql', :group => :mysql
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Anthony Figueroa
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # ActsAsFulltextable
2
+
3
+ This gem is based on the old Rails 2 plugin made by boone (https://github.com/boone/acts_as_fulltextable).
4
+
5
+ It allows you to create an auxiliary table to be used for full-text searches.
6
+ It behaves like a polymorphic association, so it can be used with any
7
+ ActiveRecord model.
8
+
9
+
10
+ It has been tested on Rails 3.1+. Ruby 1.9.1+.
11
+
12
+ ## Installation
13
+
14
+ Add this line to your application's Gemfile:
15
+
16
+ gem 'acts_as_fulltextable'
17
+
18
+ And then execute:
19
+
20
+ $ bundle install
21
+
22
+ Or install it yourself as:
23
+
24
+ $ gem 'acts_as_fulltextable', :git => 'git://github.com/toptierlabs/acts_as_fulltextable.git'
25
+
26
+
27
+ ## Usage
28
+
29
+ Create a migration for the models that you want to make searches.
30
+
31
+ $ rails generate fulltext_rows model1 model2 model3 ....
32
+ $ rake db:migrate
33
+
34
+
35
+ Add acts_as_fulltextable in any model, followed by the list of searchable fields.
36
+
37
+ i.e.
38
+ ```ruby
39
+
40
+ class Person < ActiveRecord::Base
41
+ attr_accessible :age, :description, :name
42
+
43
+ acts_as_fulltextable :description, :name
44
+ end
45
+
46
+ ```
47
+
48
+ You can either run a search on a single model:
49
+ Model.find_fulltext('query to run', :limit => 10, :offset => 0)
50
+
51
+ Or you can run it on more models at once:
52
+ FulltextRow.search('query to run', :only => [:only, :this, :models], :limit => 10, :offset => 0)
53
+
54
+ ## Warning
55
+
56
+ Should you add acts_as_fulltextable to a new model after the initial migration was run,
57
+ you should execute the following piece of code (a migration or script/console are both fine):
58
+
59
+ ```ruby
60
+
61
+ NewModel.find(:all).each {|i| i.create_fulltext_record}
62
+
63
+ ```
64
+
65
+ ## Contributing
66
+
67
+ 1. Fork it
68
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
69
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
70
+ 4. Push to the branch (`git push origin my-new-feature`)
71
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,19 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'acts_as_fulltextable/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "acts_as_fulltextable"
8
+ gem.version = ActsAsFulltextable::VERSION
9
+ gem.authors = ["Anthony Figueroa"]
10
+ gem.email = ["afigueroa@toptierlabs.com"]
11
+ gem.description = %q{Creates an auxiliary table in order to be used with full-text searches}
12
+ gem.summary = %q{It allows you to create an auxiliary to be used for full-text searches. It behaves like a polymorphic association, so it can be used with any ActiveRecord model.}
13
+ gem.homepage = "https://github.com/toptierlabs/acts_as_fulltextable"
14
+
15
+ gem.files = `git ls-files`.split($/)
16
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
+ gem.require_paths = ["lib"]
19
+ end
@@ -0,0 +1,114 @@
1
+ require "acts_as_fulltextable/version"
2
+ require "fulltext_row"
3
+
4
+ module ActsAsFulltextable
5
+ extend ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+ # Makes a model searchable.
9
+ # Takes a list of fields to use to create the index. It also take an option (:check_for_changes,
10
+ # which defaults to true) to tell the engine wether it should check if the value of a given
11
+ # instance has changed before it actually updates the associated fulltext row.
12
+ # If option :parent_id is not nulled, it is used as the field to be used as the parent of the record,
13
+ # which is useful if you want to limit your queries to a scope.
14
+ # If option :conditions is given, it should be a string containing a ruby expression that
15
+ # equates to true or nil/false. Records are tested with this condition and only those that return true
16
+ # add/update the FullTextRow. A record returning false that is already in FullTextRow is removed.
17
+ #
18
+ def acts_as_fulltextable(*attr_names)
19
+ puts '///////////////////////////'
20
+ puts attr_names
21
+ configuration = { :check_for_changes => true, :parent_id => nil, :conditions => "true" }
22
+ configuration.update(attr_names.pop) while attr_names.last.is_a?(Hash)
23
+ configuration[:fields] = attr_names.flatten.uniq.compact
24
+ puts 'Going to add act as fields'
25
+ class_attribute :fulltext_options
26
+ self.fulltext_options = configuration
27
+
28
+ extend FulltextableClassMethods
29
+ include FulltextableInstanceMethods
30
+ self.send('after_create', :create_fulltext_record)
31
+ self.send('after_update', :update_fulltext_record)
32
+ self.send('has_one', :fulltext_row, :as => :fulltextable, :dependent => :delete)
33
+ end
34
+ end
35
+
36
+ module FulltextableClassMethods
37
+
38
+ def fulltext_fields
39
+ self.fulltext_options[:fields]
40
+ end
41
+
42
+ # Performs full-text search for objects of this class.
43
+ # It takes three options:
44
+ # * limit: maximum number of rows to return. Defaults to 10.
45
+ # * offset: offset to apply to query. Defaults to 0.
46
+ # * page: only available with will_paginate plugin installed.
47
+ # * active_record: wether a ActiveRecord objects should be returned or an Array of [class_name, id]
48
+ #
49
+ def find_fulltext(query, options = {})
50
+ default_options = {:active_record => true}
51
+ options = default_options.merge(options)
52
+ unless options[:page]
53
+ options = {:limit => 10, :offset => 0}.merge(options)
54
+ end
55
+ options[:only] = self.to_s.underscore.to_sym # Only look for object belonging to this class
56
+ # Pass from what class search is invoked.
57
+ options[:search_class] = Kernel.const_get(self.to_s)
58
+
59
+ FulltextRow.search(query, options)
60
+ end
61
+ end
62
+
63
+ def self.included(receiver)
64
+ receiver.extend(ClassMethods)
65
+ end
66
+
67
+ module FulltextableInstanceMethods
68
+ # Creates the fulltext_row record for self
69
+ #
70
+ def create_fulltext_record
71
+ puts '=================='
72
+ puts self.class.to_s
73
+ puts self.id
74
+ puts self.fulltext_value
75
+ puts self.parent_id_value
76
+ FulltextRow.create(:fulltextable_type => self.class.to_s, :fulltextable_id => self.id, :value => self.fulltext_value, :parent_id => self.parent_id_value) if eval self.class.fulltext_options[:conditions]
77
+ end
78
+
79
+ # Returns the parent_id value or nil if it wasn't set.
80
+ #
81
+ def parent_id_value
82
+ self.class.fulltext_options[:parent_id].nil? ? nil : self.send(self.class.fulltext_options[:parent_id])
83
+ end
84
+
85
+ # Updates self's fulltext_row record
86
+ #
87
+ def update_fulltext_record
88
+ if eval self.class.fulltext_options[:conditions]
89
+ if self.class.fulltext_options[:check_for_changes]
90
+ row = FulltextRow.find_by_fulltextable_type_and_fulltextable_id(self.class.to_s, self.id)
91
+ # If we haven't got a row for the record, yet, create it instead of updating it.
92
+ if row.nil?
93
+ self.create_fulltext_record
94
+ return
95
+ end
96
+ end
97
+ FulltextRow.update_all(["value = ?, parent_id = ?", self.fulltext_value, self.parent_id_value], ["fulltextable_type = ? AND fulltextable_id = ?", self.class.to_s, self.id]) if !(self.class.fulltext_options[:check_for_changes]) || (row.value != self.fulltext_value) || (self.parent_id_value != row.parent_id)
98
+ else
99
+
100
+ row = FulltextRow.find_by_fulltextable_type_and_fulltextable_id(self.class.to_s, self.id)
101
+ row.destroy unless row.nil?
102
+ end
103
+ end
104
+
105
+ # Returns self's value created by concatenating fulltext fields for its class
106
+ #
107
+ def fulltext_value
108
+ full_value = self.class.fulltext_fields.map {|f| self.send(f)}.join("\n")
109
+ full_value
110
+ end
111
+ end
112
+ end
113
+
114
+ ActiveRecord::Base.send :include, ActsAsFulltextable
@@ -0,0 +1,3 @@
1
+ module ActsAsFulltextable
2
+ VERSION = "0.1"
3
+ end
@@ -0,0 +1,162 @@
1
+ # FulltextRow
2
+ #
3
+ # 2008-03-07
4
+ # Patched by Artūras Šlajus <x11@arturaz.net> for will_paginate support
5
+ # 2008-06-19
6
+ # Fixed a bug, see acts_as_fulltextable.rb
7
+ class FulltextRow < ActiveRecord::Base
8
+ attr_accessible :fulltextable_type, :fulltextable_id, :value, :parent_id
9
+
10
+ # If FULLTEXT_ROW_TABLE is set, use it as the table name
11
+ begin
12
+ set_table_name FULLTEXT_ROW_TABLE if Object.const_get('FULLTEXT_ROW_TABLE')
13
+ rescue
14
+ end
15
+ @@use_advanced_search = false
16
+ @@use_and_search = false
17
+ @@use_phrase_search = false
18
+
19
+ belongs_to :fulltextable,
20
+ :polymorphic => true
21
+ validates_presence_of :fulltextable_type, :fulltextable_id
22
+ validates_uniqueness_of :fulltextable_id,
23
+ :scope => :fulltextable_type
24
+ # Performs full-text search.
25
+ # It takes four options:
26
+ # * limit: maximum number of rows to return (use 0 for all). Defaults to 10.
27
+ # * offset: offset to apply to query. Defaults to 0.
28
+ # * page: only available with will_paginate.
29
+ # * active_record: wether a ActiveRecord objects should be returned or an Array of [class_name, id]
30
+ # * only: limit search to these classes. Defaults to all classes. (should be a symbol or an Array of symbols)
31
+ #
32
+ def self.search(query, options = {})
33
+ default_options = {:active_record => true, :parent_id => nil}
34
+ options = default_options.merge(options)
35
+ unless options[:page]
36
+ options = {:limit => 10, :offset => 0}.merge(options)
37
+ options[:offset] = 0 if options[:offset] < 0
38
+ unless options[:limit].nil?
39
+ options[:limit] = 10 if options[:limit] < 0
40
+ options[:limit] = nil if options[:limit] == 0
41
+ end
42
+ end
43
+ options[:only] = [options[:only]] unless options[:only].nil? || options[:only].is_a?(Array)
44
+ options[:only] = options[:only].map {|o| o.to_s.camelize}.uniq.compact unless options[:only].nil?
45
+
46
+ rows = raw_search(query, options[:only], options[:limit],
47
+ options[:offset], options[:parent_id], options[:page],
48
+ options[:search_class])
49
+ if options[:active_record]
50
+ types = {}
51
+ rows.each {|r| types.include?(r.fulltextable_type) ? (types[r.fulltextable_type] << r.fulltextable_id) : (types[r.fulltextable_type] = [r.fulltextable_id])}
52
+ objects = {}
53
+ types.each {|k, v| objects[k] = Object.const_get(k).find_all_by_id(v)}
54
+ objects.each {|k, v| v.sort! {|x, y| types[k].index(x.id) <=> types[k].index(y.id)}}
55
+
56
+ if defined?(WillPaginate) && options[:page]
57
+ result = WillPaginate::Collection.new(
58
+ rows.current_page,
59
+ rows.per_page,
60
+ rows.total_entries
61
+ )
62
+ else
63
+ result = []
64
+ end
65
+
66
+ rows.each {|r| result << objects[r.fulltextable_type].shift}
67
+ return result
68
+ else
69
+ return rows.map {|r| [r.fulltextable_type, r.fulltextable_id]}
70
+ end
71
+ end
72
+
73
+ # Use advanced search mechanism, instead of pure fulltext search.
74
+ #
75
+ def self.use_advanced_search!
76
+ @@use_advanced_search = true
77
+ end
78
+
79
+ # Force usage of AND search instead of OR. Works only when advanced search
80
+ # is enabled.
81
+ #
82
+ def self.use_and_search!
83
+ @@use_and_search = true
84
+ end
85
+
86
+ # Force usage of phrase search instead of OR search. Doesn't work when
87
+ # advanced search is enabled.
88
+ #
89
+ def self.use_phrase_search!
90
+ @@use_phrase_search = true
91
+ end
92
+ private
93
+ # Performs a raw full-text search.
94
+ # * query: string to be searched
95
+ # * only: limit search to these classes. Defaults to all classes.
96
+ # * limit: maximum number of rows to return (use 0 for all). Defaults to 10.
97
+ # * offset: offset to apply to query. Defaults to 0.
98
+ # * parent_id: limit query to record with passed parent_id. An Array of ids is fine.
99
+ # * page: overrides limit and offset, only available with will_paginate.
100
+ # * search_class: from what class should we take .per_page? Only with will_paginate
101
+ #
102
+ def self.raw_search(query, only, limit, offset, parent_id = nil, page = nil, search_class = nil)
103
+ unless only.nil? || only.empty?
104
+ only_condition = " AND fulltextable_type IN (#{only.map {|c| (/\A\w+\Z/ === c.to_s) ? "'#{c.to_s}'" : nil}.uniq.compact.join(',')})"
105
+ else
106
+ only_condition = ''
107
+ end
108
+ unless parent_id.nil?
109
+ if parent_id.is_a?(Array)
110
+ only_condition += " AND parent_id IN (#{parent_id.join(',')})"
111
+ else
112
+ only_condition += " AND parent_id = #{parent_id.to_i}"
113
+ end
114
+ end
115
+
116
+ if @@use_advanced_search
117
+ query_parts = query.gsub(/[\*\+\-]/, '').split(' ')
118
+ if @@use_and_search
119
+ search_query = query_parts.map {|w| "+#{w}*"}.join(' ')
120
+ else
121
+ search_query = query_parts.map {|w| "#{w}"}.join(' ')
122
+ end
123
+ matches = []
124
+ matches << [query_parts.map {|w| "+#{w}"}.join(' '), 5] # match_all_exact
125
+ if @@use_and_search
126
+ matches << [query_parts.map {|w| "+#{w}*"}.join(' '), query_parts.size > 3 ? 2 : 1] # match_all_wildcard
127
+ else
128
+ matches << [query_parts.map {|w| "#{w}"}.join(' '), query_parts.size <= 3 ? 2.5 : 1] # match_some_exact
129
+ end
130
+ #matches << [search_query, 0.5] # match_some_wildcard
131
+
132
+ relevancy = matches.map {|m| sanitize_sql(["(match(`value`) against(? in boolean mode) * #{m[1]})", m[0]])}.join(' + ')
133
+
134
+ search_options = {
135
+ :conditions => [("match(value) against(? in boolean mode)" + only_condition), search_query],
136
+ :select => "fulltext_rows.fulltextable_type, fulltext_rows.fulltextable_id, #{relevancy} AS relevancy",
137
+ :order => "relevancy DESC, value ASC"
138
+ }
139
+ else
140
+ if @@use_phrase_search
141
+ query = "\"#{query}\""
142
+ else
143
+ query = query.gsub(/(\S+)/, '\1*')
144
+ end
145
+ search_options = {
146
+ :conditions => [("match(value) against(? in boolean mode)" + only_condition), query],
147
+ :select => "fulltext_rows.fulltextable_type, fulltext_rows.fulltextable_id, #{sanitize_sql(["match(`value`) against(? in boolean mode) AS relevancy", query])}",
148
+ :order => "relevancy DESC, value ASC"
149
+ }
150
+ end
151
+
152
+ if defined?(WillPaginate) && page
153
+ search_options = search_options.merge(:page => page)
154
+ unless search_class.nil?
155
+ search_options = search_options.merge(:per_page => search_class.per_page)
156
+ end
157
+ self.paginate(:all, search_options)
158
+ else
159
+ self.find(:all, search_options.merge(:limit => limit, :offset => offset))
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,33 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+
4
+
5
+ class FulltextRowsGenerator < Rails::Generators::Base
6
+ include Rails::Generators::Migration
7
+ argument :models, :type => :array
8
+ source_root File.expand_path("../templates", __FILE__)
9
+
10
+ attr_accessor :models
11
+
12
+ def initialize(*runtime_args)
13
+ super(*runtime_args)
14
+ #puts @models.to_json
15
+ end
16
+
17
+ def create_model_migrations
18
+ migration_template("migrate.rb", 'db/migrate/create_fulltext_rows.rb')
19
+
20
+ end
21
+
22
+ def self.next_migration_number(path)
23
+ @migration_number = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i.to_s
24
+ end
25
+
26
+ protected
27
+ def banner
28
+ "Usage: #{$0} [model1 model2 model3 ...]"
29
+ end
30
+
31
+
32
+ end
33
+
@@ -0,0 +1,22 @@
1
+ class CreateFulltextRows < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :fulltext_rows, :options => 'ENGINE=MyISAM' do |t|
4
+ t.column :fulltextable_type, :string, :null => false, :limit => 50
5
+ t.column :fulltextable_id, :integer, :null => false
6
+ t.column :value, :text, :null => false, :default => ''
7
+ t.column :parent_id, :integer
8
+ end
9
+
10
+ [<%= @models.join(', ') %>].each do |m|
11
+ m.find(:all).each {|i| i.create_fulltext_record}
12
+ end
13
+
14
+ execute "CREATE FULLTEXT INDEX fulltext_index ON fulltext_rows (value)"
15
+ add_index :fulltext_rows, :parent_id
16
+ add_index :fulltext_rows, [:fulltextable_type, :fulltextable_id], :unique => true
17
+ end
18
+
19
+ def self.down
20
+ drop_table :fulltext_rows
21
+ end
22
+ end
metadata ADDED
@@ -0,0 +1,61 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acts_as_fulltextable
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.1'
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Anthony Figueroa
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-03-26 00:00:00.000000000 Z
13
+ dependencies: []
14
+ description: Creates an auxiliary table in order to be used with full-text searches
15
+ email:
16
+ - afigueroa@toptierlabs.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - .DS_Store
22
+ - .gitignore
23
+ - Gemfile
24
+ - LICENSE.txt
25
+ - README.md
26
+ - Rakefile
27
+ - acts_as_fulltextable.gemspec
28
+ - lib/.DS_Store
29
+ - lib/acts_as_fulltextable.rb
30
+ - lib/acts_as_fulltextable/version.rb
31
+ - lib/fulltext_row.rb
32
+ - lib/generators/.DS_Store
33
+ - lib/generators/fulltext_rows_generator.rb
34
+ - lib/generators/templates/migrate.rb
35
+ homepage: https://github.com/toptierlabs/acts_as_fulltextable
36
+ licenses: []
37
+ post_install_message:
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ none: false
43
+ requirements:
44
+ - - ! '>='
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ none: false
49
+ requirements:
50
+ - - ! '>='
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubyforge_project:
55
+ rubygems_version: 1.8.24
56
+ signing_key:
57
+ specification_version: 3
58
+ summary: It allows you to create an auxiliary to be used for full-text searches. It
59
+ behaves like a polymorphic association, so it can be used with any ActiveRecord
60
+ model.
61
+ test_files: []