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