pose 1.2.0 → 1.2.1

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.
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
+