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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +199 -0
  3. data/Gemfile +3 -0
  4. data/README.md +196 -0
  5. data/Rakefile +61 -0
  6. data/lib/textacular.rb +160 -0
  7. data/lib/textacular/full_text_indexer.rb +66 -0
  8. data/lib/textacular/migration_generator.rb +31 -0
  9. data/lib/textacular/postgres_module_installer.rb +57 -0
  10. data/lib/textacular/rails.rb +14 -0
  11. data/lib/textacular/searchable.rb +20 -0
  12. data/lib/textacular/tasks.rb +23 -0
  13. data/lib/textacular/trigram_installer.rb +18 -0
  14. data/lib/textacular/version.rb +7 -0
  15. data/spec/config.travis.yml +8 -0
  16. data/spec/config.yml.example +5 -0
  17. data/spec/spec_helper.rb +104 -0
  18. data/spec/support/ar_stand_in.rb +4 -0
  19. data/spec/support/character.rb +7 -0
  20. data/spec/support/game.rb +5 -0
  21. data/spec/support/game_extended_with_textacular.rb +5 -0
  22. data/spec/support/game_extended_with_textacular_and_custom_language.rb +7 -0
  23. data/spec/support/game_fail.rb +3 -0
  24. data/spec/support/game_fail_extended_with_textacular.rb +5 -0
  25. data/spec/support/not_there.rb +3 -0
  26. data/spec/support/textacular_web_comic.rb +7 -0
  27. data/spec/support/web_comic.rb +7 -0
  28. data/spec/support/web_comic_with_searchable.rb +6 -0
  29. data/spec/support/web_comic_with_searchable_name.rb +6 -0
  30. data/spec/support/web_comic_with_searchable_name_and_author.rb +6 -0
  31. data/spec/textacular/full_text_indexer_spec.rb +69 -0
  32. data/spec/textacular/migration_generator_spec.rb +67 -0
  33. data/spec/textacular/searchable_spec.rb +194 -0
  34. data/spec/textacular/trigram_installer_spec.rb +24 -0
  35. data/spec/textacular_spec.rb +287 -0
  36. metadata +210 -0
@@ -0,0 +1,4 @@
1
+ class ARStandIn < ActiveRecord::Base;
2
+ self.abstract_class = true
3
+ extend Textacular
4
+ end
@@ -0,0 +1,7 @@
1
+ class Character < ActiveRecord::Base
2
+ # string :name
3
+ # string :description
4
+ # integer :web_comic_id
5
+
6
+ belongs_to :web_comic
7
+ end
@@ -0,0 +1,5 @@
1
+ class Game < ActiveRecord::Base
2
+ # string :system
3
+ # string :title
4
+ # text :description
5
+ end
@@ -0,0 +1,5 @@
1
+ require 'support/game'
2
+
3
+ class GameExtendedWithTextacular < Game
4
+ extend Textacular
5
+ end
@@ -0,0 +1,7 @@
1
+ require 'support/game_extended_with_textacular'
2
+
3
+ class GameExtendedWithTextacularAndCustomLanguage < GameExtendedWithTextacular
4
+ def searchable_language
5
+ 'spanish'
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ require 'support/game'
2
+
3
+ class GameFail < Game; end
@@ -0,0 +1,5 @@
1
+ require 'support/game_fail'
2
+
3
+ class GameFailExtendedWithTextacular < GameFail
4
+ extend Textacular
5
+ end
@@ -0,0 +1,3 @@
1
+ require 'support/ar_stand_in'
2
+
3
+ class NotThere < ARStandIn; end
@@ -0,0 +1,7 @@
1
+ require 'support/ar_stand_in'
2
+ require 'support/character'
3
+
4
+ class TextacularWebComic < ARStandIn;
5
+ has_many :characters, :foreign_key => :web_comic_id
6
+ self.table_name = :web_comics
7
+ end
@@ -0,0 +1,7 @@
1
+ class WebComic < ActiveRecord::Base
2
+ # string :name
3
+ # string :author
4
+ # integer :id
5
+
6
+ has_many :characters
7
+ end
@@ -0,0 +1,6 @@
1
+ require 'textacular/searchable'
2
+ require 'support/web_comic'
3
+
4
+ class WebComicWithSearchable < WebComic
5
+ extend Searchable
6
+ end
@@ -0,0 +1,6 @@
1
+ require 'textacular/searchable'
2
+ require 'support/web_comic'
3
+
4
+ class WebComicWithSearchableName < WebComic
5
+ extend Searchable(:name)
6
+ end
@@ -0,0 +1,6 @@
1
+ require 'textacular/searchable'
2
+ require 'support/web_comic'
3
+
4
+ class WebComicWithSearchableNameAndAuthor < WebComic
5
+ extend Searchable(:name, :author)
6
+ end
@@ -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