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 +1 -1
- data/lib/pose.rb +3 -2
- data/lib/pose/{base_additions.rb → activerecord_base_additions.rb} +2 -1
- data/lib/pose/internal_helpers.rb +118 -0
- data/lib/pose/model_additions.rb +5 -5
- data/lib/pose/models/pose_assignment.rb +0 -4
- data/lib/pose/models/pose_word.rb +18 -4
- data/lib/pose/railtie.rb +1 -1
- data/lib/pose/static_api.rb +87 -0
- data/lib/pose/version.rb +1 -1
- data/spec/internal_helpers_spec.rb +178 -0
- data/spec/pose_api_spec.rb +279 -0
- data/spec/pose_assignment_spec.rb +10 -13
- data/spec/pose_word_spec.rb +19 -16
- data/spec/spec_helper.rb +34 -57
- data/spec/support/matchers.rb +20 -0
- data/spec/support/models.rb +8 -0
- metadata +29 -6
- data/lib/pose/static_helpers.rb +0 -173
- data/spec/pose_spec.rb +0 -401
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/
|
6
|
-
require 'pose/
|
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'
|
@@ -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
|
data/lib/pose/model_additions.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
#
|
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
|
-
|
32
|
-
new_words =
|
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
|
-
|
10
|
-
|
11
|
-
|
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
@@ -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
@@ -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
|
+
|