drtom-textacular 4.0.0.alpha.20160302
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +199 -0
- data/Gemfile +3 -0
- data/README.md +196 -0
- data/Rakefile +61 -0
- data/lib/textacular.rb +160 -0
- data/lib/textacular/full_text_indexer.rb +66 -0
- data/lib/textacular/migration_generator.rb +31 -0
- data/lib/textacular/postgres_module_installer.rb +57 -0
- data/lib/textacular/rails.rb +14 -0
- data/lib/textacular/searchable.rb +20 -0
- data/lib/textacular/tasks.rb +23 -0
- data/lib/textacular/trigram_installer.rb +18 -0
- data/lib/textacular/version.rb +7 -0
- data/spec/config.travis.yml +8 -0
- data/spec/config.yml.example +5 -0
- data/spec/spec_helper.rb +104 -0
- data/spec/support/ar_stand_in.rb +4 -0
- data/spec/support/character.rb +7 -0
- data/spec/support/game.rb +5 -0
- data/spec/support/game_extended_with_textacular.rb +5 -0
- data/spec/support/game_extended_with_textacular_and_custom_language.rb +7 -0
- data/spec/support/game_fail.rb +3 -0
- data/spec/support/game_fail_extended_with_textacular.rb +5 -0
- data/spec/support/not_there.rb +3 -0
- data/spec/support/textacular_web_comic.rb +7 -0
- data/spec/support/web_comic.rb +7 -0
- data/spec/support/web_comic_with_searchable.rb +6 -0
- data/spec/support/web_comic_with_searchable_name.rb +6 -0
- data/spec/support/web_comic_with_searchable_name_and_author.rb +6 -0
- data/spec/textacular/full_text_indexer_spec.rb +69 -0
- data/spec/textacular/migration_generator_spec.rb +67 -0
- data/spec/textacular/searchable_spec.rb +194 -0
- data/spec/textacular/trigram_installer_spec.rb +24 -0
- data/spec/textacular_spec.rb +287 -0
- metadata +210 -0
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'support/web_comic_with_searchable_name'
|
2
|
+
require 'support/web_comic_with_searchable_name_and_author'
|
3
|
+
|
4
|
+
RSpec.describe Textacular::FullTextIndexer do
|
5
|
+
context "with one specific field in a Searchable call" do
|
6
|
+
it "generates the right SQL" do
|
7
|
+
file_name = "web_comic_with_searchable_name_full_text_search"
|
8
|
+
content = <<-MIGRATION
|
9
|
+
class WebComicWithSearchableNameFullTextSearch < ActiveRecord::Migration
|
10
|
+
def self.up
|
11
|
+
execute(<<-SQL.strip)
|
12
|
+
DROP index IF EXISTS web_comics_name_fts_idx;
|
13
|
+
CREATE index web_comics_name_fts_idx
|
14
|
+
ON web_comics
|
15
|
+
USING gin(to_tsvector("english", "web_comics"."name"::text));
|
16
|
+
SQL
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.down
|
20
|
+
execute(<<-SQL.strip)
|
21
|
+
DROP index IF EXISTS web_comics_name_fts_idx;
|
22
|
+
SQL
|
23
|
+
end
|
24
|
+
end
|
25
|
+
MIGRATION
|
26
|
+
|
27
|
+
generator = double(:migration_generator)
|
28
|
+
expect(Textacular::MigrationGenerator).to receive(:new).with(content, file_name).and_return(generator)
|
29
|
+
expect(generator).to receive(:generate_migration)
|
30
|
+
|
31
|
+
Textacular::FullTextIndexer.new.generate_migration('WebComicWithSearchableName')
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
context "with two specific fields in a Searchable call" do
|
36
|
+
it "generates the right SQL" do
|
37
|
+
file_name = "web_comic_with_searchable_name_and_author_full_text_search"
|
38
|
+
content = <<-MIGRATION
|
39
|
+
class WebComicWithSearchableNameAndAuthorFullTextSearch < ActiveRecord::Migration
|
40
|
+
def self.up
|
41
|
+
execute(<<-SQL.strip)
|
42
|
+
DROP index IF EXISTS web_comics_name_fts_idx;
|
43
|
+
CREATE index web_comics_name_fts_idx
|
44
|
+
ON web_comics
|
45
|
+
USING gin(to_tsvector("english", "web_comics"."name"::text));
|
46
|
+
DROP index IF EXISTS web_comics_author_fts_idx;
|
47
|
+
CREATE index web_comics_author_fts_idx
|
48
|
+
ON web_comics
|
49
|
+
USING gin(to_tsvector("english", "web_comics"."author"::text));
|
50
|
+
SQL
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.down
|
54
|
+
execute(<<-SQL.strip)
|
55
|
+
DROP index IF EXISTS web_comics_name_fts_idx;
|
56
|
+
DROP index IF EXISTS web_comics_author_fts_idx;
|
57
|
+
SQL
|
58
|
+
end
|
59
|
+
end
|
60
|
+
MIGRATION
|
61
|
+
|
62
|
+
generator = double(:migration_generator)
|
63
|
+
expect(Textacular::MigrationGenerator).to receive(:new).with(content, file_name).and_return(generator)
|
64
|
+
expect(generator).to receive(:generate_migration)
|
65
|
+
|
66
|
+
Textacular::FullTextIndexer.new.generate_migration('WebComicWithSearchableNameAndAuthor')
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
RSpec.describe Textacular::MigrationGenerator do
|
2
|
+
describe ".stream_output" do
|
3
|
+
context "when Rails is not defined" do
|
4
|
+
subject do
|
5
|
+
Textacular::MigrationGenerator.new('filename', 'content')
|
6
|
+
end
|
7
|
+
|
8
|
+
it "points to STDOUT" do
|
9
|
+
output_stream = nil
|
10
|
+
|
11
|
+
subject.stream_output do |io|
|
12
|
+
output_stream = io
|
13
|
+
end
|
14
|
+
|
15
|
+
expect(output_stream).to eq(STDOUT)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
context "when Rails is defined" do
|
20
|
+
before do
|
21
|
+
module ::Rails
|
22
|
+
# Stub this out, sort of.
|
23
|
+
def self.root
|
24
|
+
File.join('.', 'fake_rails')
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
after do
|
30
|
+
Object.send(:remove_const, :Rails)
|
31
|
+
FileUtils.rm_rf(File.join('.', 'fake_rails'))
|
32
|
+
end
|
33
|
+
|
34
|
+
let(:now) do
|
35
|
+
Time.now
|
36
|
+
end
|
37
|
+
|
38
|
+
subject do
|
39
|
+
Textacular::MigrationGenerator.new('file_name', 'content')
|
40
|
+
end
|
41
|
+
|
42
|
+
it "points to a properly names migration file" do
|
43
|
+
expected_file_name = "./fake_rails/db/migrate/#{now.strftime('%Y%m%d%H%M%S')}_file_name.rb"
|
44
|
+
|
45
|
+
output_stream = nil
|
46
|
+
|
47
|
+
subject.stream_output(now) do |io|
|
48
|
+
output_stream = io
|
49
|
+
end
|
50
|
+
|
51
|
+
expect(output_stream.path).to eq(expected_file_name)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
it "generates the right SQL" do
|
56
|
+
content = "content\n" #newline automatically added
|
57
|
+
output = StringIO.new
|
58
|
+
|
59
|
+
generator = Textacular::MigrationGenerator.new('file_name', content)
|
60
|
+
generator.instance_variable_set(:@output_stream, output)
|
61
|
+
|
62
|
+
generator.generate_migration
|
63
|
+
|
64
|
+
expect(output.string).to eq(content)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,194 @@
|
|
1
|
+
require 'support/web_comic_with_searchable'
|
2
|
+
require 'support/web_comic_with_searchable_name'
|
3
|
+
require 'support/web_comic_with_searchable_name_and_author'
|
4
|
+
require 'support/character'
|
5
|
+
|
6
|
+
RSpec.describe "Searchable" do
|
7
|
+
context "when extending an ActiveRecord::Base subclass" do
|
8
|
+
context "with no parameters" do
|
9
|
+
let!(:questionable_content) do
|
10
|
+
WebComicWithSearchable.create(
|
11
|
+
name: 'Questionable Content',
|
12
|
+
author: 'Jeph Jaques',
|
13
|
+
)
|
14
|
+
end
|
15
|
+
|
16
|
+
let!(:johnny_wander) do
|
17
|
+
WebComicWithSearchable.create(
|
18
|
+
name: 'Johnny Wander',
|
19
|
+
author: 'Ananth & Yuko',
|
20
|
+
)
|
21
|
+
end
|
22
|
+
|
23
|
+
let!(:dominic_deegan) do
|
24
|
+
WebComicWithSearchable.create(
|
25
|
+
name: 'Dominic Deegan',
|
26
|
+
author: 'Mookie',
|
27
|
+
)
|
28
|
+
end
|
29
|
+
|
30
|
+
let!(:penny_arcade) do
|
31
|
+
WebComicWithSearchable.create(
|
32
|
+
name: 'Penny Arcade',
|
33
|
+
author: 'Tycho & Gabe',
|
34
|
+
)
|
35
|
+
end
|
36
|
+
|
37
|
+
let!(:null) do
|
38
|
+
WebComicWithSearchable.create(
|
39
|
+
author: 'Foo',
|
40
|
+
)
|
41
|
+
end
|
42
|
+
|
43
|
+
it "searches across all columns" do
|
44
|
+
expect(
|
45
|
+
WebComicWithSearchable.advanced_search("Penny")
|
46
|
+
).to eq([penny_arcade])
|
47
|
+
expect(
|
48
|
+
WebComicWithSearchable.advanced_search("Dominic")
|
49
|
+
).to eq([dominic_deegan])
|
50
|
+
end
|
51
|
+
|
52
|
+
it "ranks results, egen with NULL columns" do
|
53
|
+
comic = WebComicWithSearchable.basic_search('Foo').first
|
54
|
+
rank = comic.attributes.find { |key, value| key.to_s =~ /\Arank\d+\z/ }.last
|
55
|
+
|
56
|
+
expect(rank).to be_present
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
context "with one column as a parameter" do
|
61
|
+
let!(:questionable_content) do
|
62
|
+
WebComicWithSearchableName.create(
|
63
|
+
name: 'Questionable Content',
|
64
|
+
author: nil,
|
65
|
+
)
|
66
|
+
end
|
67
|
+
|
68
|
+
let!(:johnny_wander) do
|
69
|
+
WebComicWithSearchableName.create(
|
70
|
+
name: 'Johnny Wander',
|
71
|
+
author: 'Ananth & Yuko',
|
72
|
+
)
|
73
|
+
end
|
74
|
+
|
75
|
+
let!(:dominic_deegan) do
|
76
|
+
WebComicWithSearchableName.create(
|
77
|
+
name: 'Dominic Deegan',
|
78
|
+
author: 'Mookie',
|
79
|
+
)
|
80
|
+
end
|
81
|
+
|
82
|
+
let!(:penny_arcade) do
|
83
|
+
WebComicWithSearchableName.create(
|
84
|
+
name: 'Penny Arcade',
|
85
|
+
author: 'Tycho & Gabe',
|
86
|
+
)
|
87
|
+
end
|
88
|
+
|
89
|
+
it "only searches across the given column" do
|
90
|
+
expect(WebComicWithSearchableName.advanced_search("Penny")).to eq([penny_arcade])
|
91
|
+
|
92
|
+
expect(WebComicWithSearchableName.advanced_search("Tycho")).to be_empty
|
93
|
+
end
|
94
|
+
|
95
|
+
describe "basic search" do # Uses plainto_tsquery
|
96
|
+
["hello \\", "tebow!" , "food &"].each do |search_term|
|
97
|
+
it "works with interesting term \"#{search_term}\"" do
|
98
|
+
expect(WebComicWithSearchableName.basic_search(search_term)).to be_empty
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
describe "advanced_search" do # Uses to_tsquery
|
104
|
+
["hello \\", "tebow!" , "food &"].each do |search_term|
|
105
|
+
it "fails with interesting term \"#{search_term}\"" do
|
106
|
+
expect {
|
107
|
+
WebComicWithSearchableName.advanced_search(search_term).first
|
108
|
+
}.to raise_error(ActiveRecord::StatementInvalid)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
it "searches with negation" do
|
112
|
+
expect(WebComicWithSearchableName.advanced_search('foo & ! bar')).to be_empty
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
it "does fuzzy searching" do
|
117
|
+
expect(
|
118
|
+
WebComicWithSearchableName.fuzzy_search('Questio')
|
119
|
+
).to eq([questionable_content])
|
120
|
+
end
|
121
|
+
|
122
|
+
it "return a valid rank when fuzzy searching on NULL columns" do
|
123
|
+
qcont_with_author = questionable_content.becomes(WebComicWithSearchableNameAndAuthor)
|
124
|
+
search_result = WebComicWithSearchableNameAndAuthor.fuzzy_search('Questio')
|
125
|
+
expect([qcont_with_author]).to eq(search_result)
|
126
|
+
expect(search_result.first.attributes.find { |k, _| k[0..3] == 'rank' }.last).to be_truthy
|
127
|
+
end
|
128
|
+
|
129
|
+
it "defines :searchable_columns as private" do
|
130
|
+
expect { WebComicWithSearchableName.searchable_columns }.to raise_error(NoMethodError)
|
131
|
+
|
132
|
+
begin
|
133
|
+
WebComicWithSearchableName.searchable_columns
|
134
|
+
rescue NoMethodError => error
|
135
|
+
expect(error.message).to match(/private method/)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
it "defines #indexable_columns which returns a write-proof Enumerable" do
|
140
|
+
expect(WebComicWithSearchableName.indexable_columns).to be_an(Enumerator)
|
141
|
+
|
142
|
+
expect {
|
143
|
+
WebComicWithSearchableName.indexable_columns[0] = 'foo'
|
144
|
+
}.to raise_error(NoMethodError)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
context "with two columns as parameters" do
|
149
|
+
let!(:questionable_content) do
|
150
|
+
WebComicWithSearchableNameAndAuthor.create(
|
151
|
+
name: 'Questionable Content',
|
152
|
+
author: 'Jeph Jaques',
|
153
|
+
)
|
154
|
+
end
|
155
|
+
|
156
|
+
let!(:johnny_wander) do
|
157
|
+
WebComicWithSearchableNameAndAuthor.create(
|
158
|
+
name: 'Johnny Wander',
|
159
|
+
author: 'Ananth & Yuko',
|
160
|
+
)
|
161
|
+
end
|
162
|
+
|
163
|
+
let!(:dominic_deegan) do
|
164
|
+
WebComicWithSearchableNameAndAuthor.create(
|
165
|
+
name: 'Dominic Deegan',
|
166
|
+
author: 'Mookie',
|
167
|
+
)
|
168
|
+
end
|
169
|
+
|
170
|
+
let!(:penny_arcade) do
|
171
|
+
WebComicWithSearchableNameAndAuthor.create(
|
172
|
+
name: 'Penny Arcade',
|
173
|
+
author: 'Tycho & Gabe',
|
174
|
+
)
|
175
|
+
end
|
176
|
+
|
177
|
+
it "only searches across the given columns" do
|
178
|
+
expect(
|
179
|
+
WebComicWithSearchableNameAndAuthor.advanced_search("Penny")
|
180
|
+
).to eq([penny_arcade])
|
181
|
+
|
182
|
+
expect(
|
183
|
+
WebComicWithSearchableNameAndAuthor.advanced_search("Tycho")
|
184
|
+
).to eq([penny_arcade])
|
185
|
+
end
|
186
|
+
|
187
|
+
it "allows includes" do
|
188
|
+
expect(
|
189
|
+
WebComicWithSearchableNameAndAuthor.includes(:characters).advanced_search("Penny")
|
190
|
+
).to eq([penny_arcade])
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
RSpec.describe "Textacular::TrigramInstaller" do
|
2
|
+
let(:content) do
|
3
|
+
<<-MIGRATION
|
4
|
+
class InstallTrigram < ActiveRecord::Migration
|
5
|
+
def self.up
|
6
|
+
ActiveRecord::Base.connection.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.down
|
10
|
+
ActiveRecord::Base.connection.execute("DROP EXTENSION pg_trgm;")
|
11
|
+
end
|
12
|
+
end
|
13
|
+
MIGRATION
|
14
|
+
end
|
15
|
+
|
16
|
+
it "generates a migration" do
|
17
|
+
generator = double(:migration_generator)
|
18
|
+
|
19
|
+
expect(Textacular::MigrationGenerator).to receive(:new).with('install_trigram', content).and_return(generator)
|
20
|
+
expect(generator).to receive(:generate_migration)
|
21
|
+
|
22
|
+
Textacular::TrigramInstaller.new.generate_migration
|
23
|
+
end
|
24
|
+
end
|