pose 1.2.0 → 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Pose <a href="http://travis-ci.org/#!/kevgo/pose" target="_blank"><img src="https://secure.travis-ci.org/kevgo/pose.png" alt="Build status"></a>
1
+ # Pose <a href="http://travis-ci.org/#!/kevgo/pose" target="_blank"><img src="https://secure.travis-ci.org/kevgo/pose.png" alt="Build status"></a> <a href="https://codeclimate.com/github/kevgo/pose" target="_blank"><img src="https://codeclimate.com/badge.png" /></a>
2
2
 
3
3
  Pose ("Polymorphic Search") allows fulltext search for ActiveRecord objects.
4
4
 
data/lib/pose.rb CHANGED
@@ -2,8 +2,9 @@
2
2
  require 'rake'
3
3
  include Rake::DSL if defined? Rake::DSL
4
4
 
5
- require 'pose/static_helpers'
6
- require 'pose/base_additions'
5
+ require 'pose/static_api'
6
+ require 'pose/internal_helpers'
7
+ require 'pose/activerecord_base_additions'
7
8
  require 'pose/model_additions'
8
9
  require 'pose/railtie' if defined? Rails
9
10
  require 'pose/models/pose_assignment'
@@ -1,5 +1,6 @@
1
+ # Additions to ActiveRecord::Base.
1
2
  module Pose
2
- module BaseAdditions
3
+ module ActiveRecordBaseAdditions
3
4
 
4
5
  def posify &block
5
6
  raise "You must provide a block that returns the searchable content to 'posify'." unless block_given?
@@ -0,0 +1,118 @@
1
+ # Internal helper methods for the Pose module.
2
+ module Pose
3
+ module Helpers
4
+ class <<self
5
+
6
+ # Returns all words that begin with the given query string.
7
+ # This can be used for autocompletion functionality.
8
+ #
9
+ # @param [String]
10
+ # @return [Array<String>]
11
+ def autocomplete_words query
12
+ return [] if query.blank?
13
+ PoseWord.where('text LIKE ?', "#{Pose::Helpers.root_word(query)[0]}%").map(&:text)
14
+ end
15
+
16
+
17
+ # Returns all strings that are in new_words, but not in existing_words.
18
+ # Helper method.
19
+ #
20
+ # @param [Array<String>] existing_words The words that are already associated with the object.
21
+ # @param [Array<String>] new_words The words thet the object should have from now on.
22
+ #
23
+ # @return [Array<String>] The words that need to be added to the existing_words array.
24
+ def get_words_to_add existing_words, new_words
25
+ new_words - existing_words.map(&:text)
26
+ end
27
+
28
+
29
+ # Helper method.
30
+ # Returns the id of all word objects that are in existing_words, but not in new_words.
31
+ #
32
+ # @param [Array<String>] existing_words The words that are already associated with the object.
33
+ # @param [Array<String>] new_words The words thet the object should have from now on.
34
+ #
35
+ # @return [Array<String>] The words that need to be removed from the existing_words array.
36
+ def get_words_to_remove existing_words, new_words
37
+ existing_words.map do |existing_word|
38
+ existing_word unless new_words.include?(existing_word.text)
39
+ end.compact
40
+ end
41
+
42
+
43
+ # Returns whether the given string is a URL.
44
+ #
45
+ # @param [String] word The string to check.
46
+ #
47
+ # @return [Boolean]
48
+ def is_url? word
49
+ URI::parse(word).scheme == 'http'
50
+ rescue URI::InvalidURIError
51
+ false
52
+ end
53
+
54
+
55
+ # Merges the given posable object ids for a single query word into the given search result.
56
+ def merge_search_result_word_matches result, class_name, ids
57
+ if result.has_key? class_name
58
+ result[class_name] = result[class_name] & ids
59
+ else
60
+ result[class_name] = ids
61
+ end
62
+ end
63
+
64
+
65
+ # Returns a hash mapping classes to ids for the a single given word.
66
+ def search_classes_and_ids_for_word word, class_names
67
+ result = {}.tap { |hash| class_names.each { |class_name| hash[class_name] = [] }}
68
+ query = PoseAssignment.joins(:pose_word) \
69
+ .select('pose_assignments.posable_id, pose_assignments.posable_type') \
70
+ .where('pose_words.text LIKE ?', "#{word}%") \
71
+ .where('posable_type IN (?)', class_names)
72
+ PoseAssignment.connection.select_all(query.to_sql).each do |pose_assignment|
73
+ result[pose_assignment['posable_type']] << pose_assignment['posable_id'].to_i
74
+ end
75
+ result
76
+ end
77
+
78
+
79
+ # Makes the given input an array.
80
+ def make_array input
81
+ [input].flatten
82
+ end
83
+
84
+
85
+ # Returns the search terms that are contained in the given query.
86
+ def query_terms query
87
+ query.split(' ').map{|query_word| Pose::Helpers.root_word query_word}.flatten.uniq
88
+ end
89
+
90
+ # Simplifies the given word to a generic search form.
91
+ #
92
+ # @param [String] raw_word The word to make searchable.
93
+ #
94
+ # @return [String] The stemmed version of the word.
95
+ def root_word raw_word
96
+ result = []
97
+ raw_word_copy = raw_word[0..-1]
98
+ raw_word_copy.gsub! '%20', ' '
99
+ raw_word_copy.gsub! /[()*<>'",;\?\-\=&%#]/, ' '
100
+ raw_word_copy.gsub! /\s+/, ' '
101
+ raw_word_copy.split(' ').each do |word|
102
+ if Pose::Helpers.is_url?(word)
103
+ result.concat word.split(/[\.\/\:]/).delete_if(&:blank?)
104
+ else
105
+ word.gsub! /[\-\/\._:]/, ' '
106
+ word.gsub! /\s+/, ' '
107
+ word.split(' ').each do |w|
108
+ stemmed_word = w.parameterize.singularize
109
+ result.concat stemmed_word.split ' '
110
+ end
111
+ end
112
+ end
113
+ result.uniq
114
+ end
115
+
116
+ end
117
+ end
118
+ end
@@ -1,4 +1,4 @@
1
- # This Module contains code that gets added to classes that are posified.
1
+ # Additions to posified ActiveRecord models.
2
2
  module Pose
3
3
  module ModelAdditions
4
4
  extend ActiveSupport::Concern
@@ -28,16 +28,16 @@ module Pose
28
28
  def update_pose_words
29
29
 
30
30
  # Step 1: get an array of all words for the current object.
31
- search_string = instance_eval &(self.class.pose_content)
32
- new_words = search_string.to_s.split(' ').map { |word| Pose.root_word(word) }.flatten.uniq
31
+ search_text = instance_eval &(self.class.pose_content)
32
+ new_words = Pose::Helpers.query_terms search_text.to_s
33
33
 
34
34
  # Step 2: Add new words to the search index.
35
- Pose.get_words_to_add(self.pose_words, new_words).each do |word_to_add|
35
+ Pose::Helpers.get_words_to_add(self.pose_words, new_words).each do |word_to_add|
36
36
  self.pose_words << PoseWord.find_or_create_by_text(word_to_add)
37
37
  end
38
38
 
39
39
  # Step 3: Remove now obsolete words from search index.
40
- Pose.get_words_to_remove(self.pose_words, new_words).each do |word_to_remove|
40
+ Pose::Helpers.get_words_to_remove(self.pose_words, new_words).each do |word_to_remove|
41
41
  self.pose_words.delete word_to_remove
42
42
  end
43
43
  end
@@ -4,10 +4,6 @@ class PoseAssignment < ActiveRecord::Base
4
4
  belongs_to :posable, polymorphic: true
5
5
  attr_accessible :pose_word, :posable
6
6
 
7
-
8
- attr_accessible :pose_word, :posable
9
-
10
-
11
7
  # Removes all PoseAssignments for the given class.
12
8
  def self.delete_class_index clazz
13
9
  PoseAssignment.delete_all(['posable_type=?', clazz.name])
@@ -1,14 +1,28 @@
1
1
  # A single word in the search index.
2
2
  class PoseWord < ActiveRecord::Base
3
3
  has_many :pose_assignments
4
- attr_accessible :text
5
4
 
6
5
  attr_accessible :text
7
6
 
8
7
  def self.remove_unused_words progress_bar = nil
9
- PoseWord.find_each(include: [:pose_assignments], batch_size: 5000) do |pose_word|
10
- pose_word.delete if pose_word.pose_assignments.size == 0
11
- progress_bar.inc if progress_bar
8
+ if ActiveRecord::Base.connection.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
9
+ # Will generate something like:
10
+ #
11
+ # DELETE FROM "pose_words" WHERE "pose_words"."id" IN
12
+ # (SELECT "pose_words"."id" FROM "pose_words" INNER JOIN "pose_assignments" ON "pose_assignments"."id" = "pose_words"."user_id"
13
+ # HAVING.... GROUP BY "pose_words"."id")
14
+ PoseWord.delete_all(id: PoseWord.select("pose_words.id").
15
+ joins("LEFT OUTER JOIN pose_assignments ON pose_assignments.pose_word_id = pose_words.id").
16
+ group("pose_words.id").
17
+ having("COUNT(pose_assignments.id) = 0"))
18
+ else
19
+ # NOTE (SZ): do not use find_each uses batch_size == 100.
20
+ # Use find_in_batches instead.
21
+ #
22
+ PoseWord.select(:id).find_in_batches.each(include: [:pose_assignments], batch_size: 5000) do |pose_word|
23
+ pose_word.delete if pose_word.pose_assignments.size == 0
24
+ progress_bar.inc if progress_bar
25
+ end
12
26
  end
13
27
  end
14
28
  end
data/lib/pose/railtie.rb CHANGED
@@ -7,7 +7,7 @@ module Pose
7
7
  # Add the Pose additions to ActiveRecord::Base once it is loaded.
8
8
  initializer 'Pose.base_additions' do
9
9
  ActiveSupport.on_load :active_record do
10
- extend Pose::BaseAdditions
10
+ extend Pose::ActiveRecordBaseAdditions
11
11
  end
12
12
  end
13
13
 
@@ -0,0 +1,87 @@
1
+ # Static helper methods of the Pose gem.
2
+ module Pose
3
+
4
+ # By default, doesn't run in tests.
5
+ # Set this to true to test the search functionality.
6
+ CONFIGURATION = { search_in_tests: false }
7
+
8
+ class <<self
9
+
10
+ ######################
11
+ # PUBLIC METHODS
12
+ #
13
+
14
+ # Returns whether Pose is configured to perform search.
15
+ # This setting exists to disable search in tests.
16
+ #
17
+ # @return [false, true]
18
+ def perform_search?
19
+ !(Rails.env == 'test' and !CONFIGURATION[:search_in_tests])
20
+ end
21
+
22
+
23
+ # Returns all objects matching the given query.
24
+ #
25
+ # @param [String] query
26
+ # @param (Class|[Array<Class>]) classes
27
+ # @param [Hash?] options Additional options.
28
+ #
29
+ # @return [Hash<Class, ActiveRecord::Relation>]
30
+ def search query, classes, options = {}
31
+
32
+
33
+ # Get the ids of the results.
34
+ class_names = Pose::Helpers.make_array(classes).map &:name
35
+ result_classes_and_ids = {}
36
+ Pose::Helpers.query_terms(query).each do |query_word|
37
+ Pose::Helpers.search_classes_and_ids_for_word(query_word, class_names).each do |class_name, ids|
38
+ Pose::Helpers.merge_search_result_word_matches result_classes_and_ids, class_name, ids
39
+ end
40
+ end
41
+
42
+ # Load the results by id.
43
+ {}.tap do |result|
44
+ result_classes_and_ids.each do |class_name, ids|
45
+ result_class = Kernel.const_get class_name
46
+
47
+ if ids.size == 0
48
+ # Handle no results.
49
+ result[result_class] = []
50
+
51
+ else
52
+ # Here we have results.
53
+
54
+ # Limit.
55
+ ids = ids.slice(0, options[:limit]) if options[:limit]
56
+
57
+ if options[:result_type] == :ids
58
+ # Ids requested for result.
59
+
60
+ if options[:scope].blank?
61
+ # No scope.
62
+ result[result_class] = ids
63
+ else
64
+ # We have a scope.
65
+ options[:scope].each do |scope|
66
+ query = result_class.select('id').where('id IN (?)', ids).where(scope).to_sql
67
+ result[result_class] = result_class.connection.select_values(query).map(&:to_i)
68
+ end
69
+ end
70
+
71
+ else
72
+ # Classes requested for result.
73
+
74
+ result[result_class] = result_class.where(id: ids)
75
+ if options.has_key? :scope
76
+ options[:scope].each do |scope|
77
+ result[result_class] = result[result_class].where('id IN (?)', ids).where(scope)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+
86
+ end
87
+ end
data/lib/pose/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Pose
2
- VERSION = "1.2.0"
2
+ VERSION = "1.2.1"
3
3
  end
@@ -0,0 +1,178 @@
1
+ # encoding: utf-8
2
+
3
+ require "spec_helper"
4
+
5
+ describe Pose::Helpers do
6
+
7
+ describe '::get_words_to_add' do
8
+ let(:one) { PoseWord.new(text: 'one') }
9
+ let(:two) { PoseWord.new(text: 'two') }
10
+
11
+ context 'having a new word to be added' do
12
+ it 'returns an array with strings that need to be added' do
13
+ Pose::Helpers.get_words_to_add([one, two], %w{one three}).should eql(['three'])
14
+ end
15
+ end
16
+
17
+ context 'nothing to add' do
18
+ it 'returns an empty array' do
19
+ Pose::Helpers.get_words_to_add([one, two], %w{one two}).should be_empty
20
+ end
21
+ end
22
+ end
23
+
24
+
25
+ describe '::get_words_to_remove' do
26
+ let(:one) { PoseWord.new(text: 'one') }
27
+ let(:two) { PoseWord.new(text: 'two') }
28
+
29
+ it "returns an array of word objects that need to be removed" do
30
+ Pose::Helpers.get_words_to_remove([one, two], %w{one three}).should eql([two])
31
+ end
32
+
33
+ it 'returns an empty array if there are no words to be removed' do
34
+ Pose::Helpers.get_words_to_remove([one, two], %w{one two}).should be_empty
35
+ end
36
+ end
37
+
38
+
39
+ describe 'make_array' do
40
+
41
+ it 'converts a single value into an array' do
42
+ Pose::Helpers.make_array(1).should == [1]
43
+ end
44
+
45
+ it 'leaves arrays as arrays' do
46
+ Pose::Helpers.make_array([1]).should == [1]
47
+ end
48
+ end
49
+
50
+
51
+ describe 'merge_search_result_word_matches' do
52
+ context 'given a new class name' do
53
+
54
+ before :each do
55
+ @result = {}
56
+ end
57
+
58
+ it 'sets the given ids as the ids for this class name' do
59
+ Pose::Helpers.merge_search_result_word_matches @result, 'class1', [1, 2]
60
+ @result.should == { 'class1' => [1, 2] }
61
+ end
62
+ end
63
+
64
+ context 'given a class name with already existing ids from another word' do
65
+
66
+ before :each do
67
+ @result = { 'class1' => [1, 2] }
68
+ end
69
+
70
+ it 'only keeps the ids that are included in both sets' do
71
+ Pose::Helpers.merge_search_result_word_matches @result, 'class1', [1, 3]
72
+ @result.should == { 'class1' => [1] }
73
+ end
74
+ end
75
+ end
76
+
77
+
78
+ describe 'query_terms' do
79
+ it 'returns all individual words resulting from the given query' do
80
+ Pose::Helpers.query_terms('foo bar').should == ['foo', 'bar']
81
+ end
82
+
83
+ it 'converts the individual words into their root form' do
84
+ Pose::Helpers.query_terms('bars').should == ['bar']
85
+ end
86
+
87
+ it 'splits complex words into separate terms' do
88
+ Pose::Helpers.query_terms('one-two').should == ['one', 'two']
89
+ end
90
+
91
+ it 'removes duplicates' do
92
+ Pose::Helpers.query_terms('foo-bar foo').should == ['foo', 'bar']
93
+ end
94
+ end
95
+
96
+
97
+ describe 'root_word' do
98
+
99
+ it 'converts words into singular' do
100
+ Pose::Helpers.root_word('bars').should eql(['bar'])
101
+ end
102
+
103
+ it 'removes special characters' do
104
+ Pose::Helpers.root_word('(bar').should == ['bar']
105
+ Pose::Helpers.root_word('bar)').should == ['bar']
106
+ Pose::Helpers.root_word('(bar)').should == ['bar']
107
+ Pose::Helpers.root_word('>foo').should == ['foo']
108
+ Pose::Helpers.root_word('<foo').should == ['foo']
109
+ Pose::Helpers.root_word('"foo"').should == ['foo']
110
+ Pose::Helpers.root_word('"foo').should == ['foo']
111
+ Pose::Helpers.root_word("'foo'").should == ['foo']
112
+ Pose::Helpers.root_word("'foo's").should == ['foo']
113
+ Pose::Helpers.root_word("foo?").should == ['foo']
114
+ Pose::Helpers.root_word("foo!").should == ['foo']
115
+ Pose::Helpers.root_word("foo/bar").should == ['foo', 'bar']
116
+ Pose::Helpers.root_word("foo-bar").should == ['foo', 'bar']
117
+ Pose::Helpers.root_word("foo--bar").should == ['foo', 'bar']
118
+ Pose::Helpers.root_word("foo.bar").should == ['foo', 'bar']
119
+ end
120
+
121
+ it 'removes umlauts' do
122
+ Pose::Helpers.root_word('fünf').should == ['funf']
123
+ end
124
+
125
+ it 'splits up numbers' do
126
+ Pose::Helpers.root_word('11.2.2011').should == ['11', '2', '2011']
127
+ Pose::Helpers.root_word('11-2-2011').should == ['11', '2', '2011']
128
+ Pose::Helpers.root_word('30:4-5').should == ['30', '4', '5']
129
+ end
130
+
131
+ it 'converts into lowercase' do
132
+ Pose::Helpers.root_word('London').should == ['london']
133
+ end
134
+
135
+ it "stores single-letter words" do
136
+ Pose::Helpers.root_word('a b').should == ['a', 'b']
137
+ end
138
+
139
+ it "does't encode external URLs" do
140
+ Pose::Helpers.root_word('http://web.com').should == ['http', 'web', 'com']
141
+ end
142
+
143
+ it "doesn't store empty words" do
144
+ Pose::Helpers.root_word(' one two ').should == ['one', 'two']
145
+ end
146
+
147
+ it "removes duplicates" do
148
+ Pose::Helpers.root_word('one_one').should == ['one']
149
+ Pose::Helpers.root_word('one one').should == ['one']
150
+ end
151
+
152
+ it "splits up complex URLs" do
153
+ Pose::Helpers.root_word('books?id=p7uyWPcVGZsC&dq=closure%20definitive%20guide&pg=PP1#v=onepage&q&f=false').should eql([
154
+ "book", "id", "p7uywpcvgzsc", "dq", "closure", "definitive", "guide", "pg", "pp1", "v", "onepage", "q", "f", "false"])
155
+ end
156
+ end
157
+
158
+
159
+ describe 'search_classes_and_ids_for_word' do
160
+
161
+ it 'returns a hash that contains all the given classes' do
162
+ result = Pose::Helpers.search_classes_and_ids_for_word 'foo', %w{PosableOne PosableTwo}
163
+ result.keys.sort.should == %w{PosableOne PosableTwo}
164
+ end
165
+
166
+ it 'returns the ids of all the posable objects that include the given word' do
167
+ pos1 = PosableOne.create text: 'one two'
168
+ pos2 = PosableTwo.create text: 'one three'
169
+ pos3 = PosableTwo.create text: 'two three'
170
+
171
+ result = Pose::Helpers.search_classes_and_ids_for_word 'one', %w{PosableOne PosableTwo}
172
+
173
+ result['PosableOne'].should == [pos1.id]
174
+ result['PosableTwo'].should == [pos2.id]
175
+ end
176
+ end
177
+ end
178
+