runestone 1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.tm_properties +1 -0
- data/Gemfile +4 -0
- data/LICENSE +21 -0
- data/README.md +10 -0
- data/Rakefile +14 -0
- data/db/migrate/20181101150207_create_ts_tables.rb +31 -0
- data/lib/runestone.rb +103 -0
- data/lib/runestone/active_record/base_methods.rb +135 -0
- data/lib/runestone/active_record/relation_methods.rb +83 -0
- data/lib/runestone/corpus.rb +45 -0
- data/lib/runestone/engine.rb +21 -0
- data/lib/runestone/indexing_job.rb +8 -0
- data/lib/runestone/model.rb +92 -0
- data/lib/runestone/settings.rb +106 -0
- data/lib/runestone/version.rb +3 -0
- data/lib/runestone/web_search.rb +203 -0
- data/lib/runestone/web_search/and.rb +17 -0
- data/lib/runestone/web_search/or.rb +11 -0
- data/lib/runestone/web_search/phrase.rb +19 -0
- data/lib/runestone/web_search/token.rb +27 -0
- data/runestone.gemspec +32 -0
- data/test/corpus_test.rb +42 -0
- data/test/database.rb +119 -0
- data/test/delayed_index_test.rb +34 -0
- data/test/helper_test.rb +40 -0
- data/test/highlight_test.rb +26 -0
- data/test/indexing_test.rb +151 -0
- data/test/multi_index_test.rb +177 -0
- data/test/query_test.rb +129 -0
- data/test/synonym_test.rb +128 -0
- data/test/test_helper.rb +185 -0
- metadata +239 -0
@@ -0,0 +1,177 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class MultiIndexTest < ActiveSupport::TestCase
|
4
|
+
|
5
|
+
test 'simple index' do
|
6
|
+
address = assert_difference 'Runestone::Model.count', 2 do
|
7
|
+
assert_sql(/setweight\(to_tsvector\('english', 'empire state building'\), 'A'\)/, /setweight\(to_tsvector\('russian', 'эмпайр-стейт-билдинг'\), 'A'\)/) do
|
8
|
+
Building.create(name_en: 'Empire State Building', name_ru: 'Эмпайр-Стейт-Билдинг')
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
assert_equal([
|
13
|
+
[
|
14
|
+
'Building', address.id,
|
15
|
+
nil, 'english',
|
16
|
+
{"name" => "Empire State Building"},
|
17
|
+
"'build':3A 'empir':1A 'state':2A"
|
18
|
+
],
|
19
|
+
[
|
20
|
+
'Building', address.id,
|
21
|
+
nil, 'russian',
|
22
|
+
{"name" => "Эмпайр-Стейт-Билдинг"},
|
23
|
+
"'билдинг':4A 'стейт':3A 'эмпайр':2A 'эмпайр-стейт-билдинг':1A"
|
24
|
+
],
|
25
|
+
], address.runestones.map { |rs|
|
26
|
+
[
|
27
|
+
rs.record_type, rs.record_id,
|
28
|
+
rs.name, rs.dictionary,
|
29
|
+
rs.data,
|
30
|
+
rs.vector
|
31
|
+
]
|
32
|
+
})
|
33
|
+
|
34
|
+
query = Runestone::Model.search('empire')
|
35
|
+
assert_sql(<<~SQL, query.to_sql)
|
36
|
+
SELECT "runestones".*, ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', 'empire:*')) AS rank0
|
37
|
+
FROM "runestones"
|
38
|
+
WHERE "runestones"."vector" @@ to_tsquery('simple_unaccent', 'empire:*')
|
39
|
+
ORDER BY rank0 DESC
|
40
|
+
SQL
|
41
|
+
|
42
|
+
query = Runestone::Model.search('empire', dictionary: 'english')
|
43
|
+
assert_sql(<<~SQL, query.to_sql)
|
44
|
+
SELECT
|
45
|
+
"runestones".*, ts_rank_cd("runestones"."vector", to_tsquery('english', 'empire:*')) AS rank0
|
46
|
+
FROM "runestones"
|
47
|
+
WHERE "runestones"."vector" @@ to_tsquery('english', 'empire:*')
|
48
|
+
AND "runestones"."dictionary" = 'english'
|
49
|
+
ORDER BY rank0 DESC
|
50
|
+
SQL
|
51
|
+
|
52
|
+
query = Runestone::Model.search('Эмпайр', dictionary: 'russian')
|
53
|
+
assert_sql(<<~SQL, query.to_sql)
|
54
|
+
SELECT "runestones".*, ts_rank_cd("runestones"."vector", to_tsquery('russian', 'эмпайр:*')) AS rank0
|
55
|
+
FROM "runestones"
|
56
|
+
WHERE "runestones"."vector" @@ to_tsquery('russian', 'эмпайр:*')
|
57
|
+
AND "runestones"."dictionary" = 'russian'
|
58
|
+
ORDER BY rank0 DESC
|
59
|
+
SQL
|
60
|
+
end
|
61
|
+
|
62
|
+
test 'empty index' do
|
63
|
+
address = assert_difference 'Runestone::Model.count', 1 do
|
64
|
+
Address.create(name: nil)
|
65
|
+
end
|
66
|
+
|
67
|
+
assert_equal([[
|
68
|
+
'Address', address.id,
|
69
|
+
{},
|
70
|
+
""
|
71
|
+
]], address.runestones.map { |rs|
|
72
|
+
[
|
73
|
+
rs.record_type,
|
74
|
+
rs.record_id,
|
75
|
+
rs.data,
|
76
|
+
rs.vector
|
77
|
+
]
|
78
|
+
})
|
79
|
+
end
|
80
|
+
|
81
|
+
test 'complex index' do
|
82
|
+
property = assert_difference 'Runestone::Model.count', 3 do
|
83
|
+
Property.create(name: 'Property name', addresses: [
|
84
|
+
Address.create(name: 'Address 1'),
|
85
|
+
Address.create(name: 'Address 2')
|
86
|
+
])
|
87
|
+
end
|
88
|
+
|
89
|
+
assert_equal([[
|
90
|
+
'Property', property.id,
|
91
|
+
{
|
92
|
+
"name"=>"Property name",
|
93
|
+
"addresses"=>[
|
94
|
+
{ "id" => property.addresses.first.id, "name" => "Address 1" },
|
95
|
+
{ "id" => property.addresses.last.id, "name" => "Address 2" }
|
96
|
+
]
|
97
|
+
},
|
98
|
+
"'1':4C '2':6C 'address':3C,5C 'name':2A 'property':1A"
|
99
|
+
]], property.runestones.map { |rs|
|
100
|
+
[
|
101
|
+
rs.record_type,
|
102
|
+
rs.record_id,
|
103
|
+
rs.data,
|
104
|
+
rs.vector
|
105
|
+
]
|
106
|
+
})
|
107
|
+
end
|
108
|
+
|
109
|
+
test 'index gets created on Model.create' do
|
110
|
+
address = assert_difference 'Runestone::Model.count', 1 do
|
111
|
+
Address.create(name: 'Address name')
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
test 'index gets updated on Model.create' do
|
116
|
+
address = Address.create(name: 'Address name')
|
117
|
+
assert_no_difference 'Runestone::Model.count' do
|
118
|
+
address.update!(name: 'Address name two')
|
119
|
+
end
|
120
|
+
|
121
|
+
assert_equal([[
|
122
|
+
'Address', address.id,
|
123
|
+
{"name" => "Address name two"},
|
124
|
+
"'address':1A 'name':2A 'two':3A"
|
125
|
+
]], address.runestones.map { |rs|
|
126
|
+
[
|
127
|
+
rs.record_type,
|
128
|
+
rs.record_id,
|
129
|
+
rs.data,
|
130
|
+
rs.vector
|
131
|
+
]
|
132
|
+
})
|
133
|
+
end
|
134
|
+
|
135
|
+
test 'index gets deleted on Model.destroy' do
|
136
|
+
address = Address.create(name: 'Address name')
|
137
|
+
assert_difference 'Runestone::Model.count', -1 do
|
138
|
+
address.destroy!
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
test 'reindex! deleted removed records' do
|
143
|
+
a1 = Address.create(name: 'one')
|
144
|
+
a2 = Address.create(name: 'two')
|
145
|
+
|
146
|
+
assert_no_difference 'Runestone::Model.count' do
|
147
|
+
a2.delete
|
148
|
+
end
|
149
|
+
|
150
|
+
assert_difference 'Runestone::Model.count', -1 do
|
151
|
+
Address.reindex!
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
test 'reindex! updates runestones on outdated indexes' do
|
156
|
+
address = Address.create(name: 'one')
|
157
|
+
address.update_columns(name: 'two')
|
158
|
+
|
159
|
+
assert_equal(["'one':1A"], address.runestones.map(&:vector))
|
160
|
+
assert_no_difference 'Runestone::Model.count' do
|
161
|
+
Address.reindex!
|
162
|
+
end
|
163
|
+
assert_equal(["'two':1A"], address.runestones.map { |rs| rs.reload.vector })
|
164
|
+
end
|
165
|
+
|
166
|
+
test 'reindex! creates index if not there' do
|
167
|
+
address = Address.create(name: 'one')
|
168
|
+
address.runestones.each(&:delete)
|
169
|
+
|
170
|
+
assert_equal 0, address.reload.runestones.size
|
171
|
+
assert_difference 'Runestone::Model.count', 1 do
|
172
|
+
Address.reindex!
|
173
|
+
end
|
174
|
+
assert_equal(["'one':1A"], address.reload.runestones.map(&:vector))
|
175
|
+
end
|
176
|
+
|
177
|
+
end
|
data/test/query_test.rb
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class QueryTest < ActiveSupport::TestCase
|
4
|
+
|
5
|
+
test '::search(query)' do
|
6
|
+
query = Runestone::Model.search('seaerch for this')
|
7
|
+
|
8
|
+
assert_sql(<<~SQL, query.to_sql)
|
9
|
+
SELECT "runestones".*, ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', 'seaerch & for & this:*')) AS rank0
|
10
|
+
FROM "runestones"
|
11
|
+
WHERE "runestones"."vector" @@ to_tsquery('simple_unaccent', 'seaerch & for & this:*')
|
12
|
+
ORDER BY rank0 DESC
|
13
|
+
SQL
|
14
|
+
end
|
15
|
+
|
16
|
+
test '::search(query) normalizes Unicode strings' do
|
17
|
+
query = Runestone::Model.search("the search for \u0065\u0301")
|
18
|
+
|
19
|
+
assert_sql(<<~SQL, query.to_sql)
|
20
|
+
SELECT "runestones".*, ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', 'the & search & for & \u00e9:*')) AS rank0
|
21
|
+
FROM "runestones"
|
22
|
+
WHERE "runestones"."vector" @@ to_tsquery('simple_unaccent', 'the & search & for & \u00e9:*')
|
23
|
+
ORDER BY rank0 DESC
|
24
|
+
SQL
|
25
|
+
end
|
26
|
+
|
27
|
+
test "::search(query with ')" do
|
28
|
+
query = Runestone::Model.search("seaerch for ' this")
|
29
|
+
assert_sql(<<~SQL, query.to_sql)
|
30
|
+
SELECT "runestones".*, ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', 'seaerch & for & this:*')) AS rank0
|
31
|
+
FROM "runestones"
|
32
|
+
WHERE "runestones"."vector" @@ to_tsquery('simple_unaccent', 'seaerch & for & this:*')
|
33
|
+
ORDER BY rank0 DESC
|
34
|
+
SQL
|
35
|
+
|
36
|
+
query = Runestone::Model.search("seaerch for james' map")
|
37
|
+
assert_sql(<<~SQL, query.to_sql)
|
38
|
+
SELECT "runestones".*, ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', 'seaerch & for & james'' & map:*')) AS rank0
|
39
|
+
FROM "runestones"
|
40
|
+
WHERE "runestones"."vector" @@ to_tsquery('simple_unaccent', 'seaerch & for & james'' & map:*')
|
41
|
+
ORDER BY rank0 DESC
|
42
|
+
SQL
|
43
|
+
end
|
44
|
+
|
45
|
+
test '::search(query, prefix: :all)' do
|
46
|
+
query = Runestone::Model.search('seaerch for this', prefix: :all)
|
47
|
+
|
48
|
+
assert_sql(<<~SQL, query.to_sql)
|
49
|
+
SELECT "runestones".*, ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', 'seaerch:* & for:* & this:*')) AS rank0
|
50
|
+
FROM "runestones"
|
51
|
+
WHERE "runestones"."vector" @@ to_tsquery('simple_unaccent', 'seaerch:* & for:* & this:*')
|
52
|
+
ORDER BY rank0 DESC
|
53
|
+
SQL
|
54
|
+
end
|
55
|
+
|
56
|
+
test '::search(query).limit(N)' do
|
57
|
+
query = Runestone::Model.search('seaerch for this').limit(10)
|
58
|
+
|
59
|
+
assert_sql(<<~SQL, query.to_sql)
|
60
|
+
SELECT "runestones".*, ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', 'seaerch & for & this:*')) AS rank0
|
61
|
+
FROM "runestones"
|
62
|
+
WHERE "runestones"."vector" @@ to_tsquery('simple_unaccent', 'seaerch & for & this:*')
|
63
|
+
ORDER BY rank0 DESC
|
64
|
+
LIMIT 10
|
65
|
+
SQL
|
66
|
+
end
|
67
|
+
|
68
|
+
test 'Model::search(query)' do
|
69
|
+
query = Property.search('seaerch for this')
|
70
|
+
|
71
|
+
assert_sql(<<~SQL, query.to_sql)
|
72
|
+
SELECT
|
73
|
+
"properties".*, ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', 'seaerch & for & this:*')) AS rank0
|
74
|
+
FROM "properties"
|
75
|
+
INNER JOIN "runestones"
|
76
|
+
ON "runestones"."record_id" = "properties"."id"
|
77
|
+
AND "runestones"."record_type" = 'Property'
|
78
|
+
WHERE
|
79
|
+
"runestones"."vector" @@ to_tsquery('simple_unaccent', 'seaerch & for & this:*')
|
80
|
+
ORDER BY rank0 DESC
|
81
|
+
SQL
|
82
|
+
end
|
83
|
+
|
84
|
+
test 'Model::search(query) with misspelling in query' do
|
85
|
+
Runestone::Corpus.add('search')
|
86
|
+
query = Property.search('seaerch for this')
|
87
|
+
|
88
|
+
assert_sql(<<~SQL, query.to_sql)
|
89
|
+
SELECT
|
90
|
+
"properties".*,
|
91
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', 'seaerch & for & this:*')) AS rank0,
|
92
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '(seaerch | search) & for & this:*')) AS rank1
|
93
|
+
FROM "properties"
|
94
|
+
INNER JOIN "runestones" ON
|
95
|
+
"runestones"."record_id" = "properties"."id"
|
96
|
+
AND "runestones"."record_type" = 'Property'
|
97
|
+
WHERE
|
98
|
+
"runestones"."vector" @@ to_tsquery('simple_unaccent', '(seaerch | search) & for & this:*')
|
99
|
+
ORDER BY
|
100
|
+
rank0 DESC,
|
101
|
+
rank1 DESC
|
102
|
+
SQL
|
103
|
+
end
|
104
|
+
|
105
|
+
test "::typos with special chars" do
|
106
|
+
Runestone::Corpus.add(*%w{avenue aveneue avenue)})
|
107
|
+
|
108
|
+
words = "AVENUE AV AVE AVN AVEN AVENU AVNUE".split(/\s+/)
|
109
|
+
words.each do |word|
|
110
|
+
Runestone.add_synonym(word, *words.select { |w| w != word })
|
111
|
+
end
|
112
|
+
|
113
|
+
assert_sql(<<~SQL, Runestone::Model.search('avenue').to_sql)
|
114
|
+
SELECT
|
115
|
+
"runestones".*,
|
116
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', 'avenue:*')) AS rank0,
|
117
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '(avenue:* | aveneue)')) AS rank1,
|
118
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '((avenue:* | aveneue) | av | ave | avn | aven | avenu | avnue)')) AS rank2
|
119
|
+
FROM "runestones"
|
120
|
+
WHERE
|
121
|
+
"runestones"."vector" @@ to_tsquery('simple_unaccent', '((avenue:* | aveneue) | av | ave | avn | aven | avenu | avnue)')
|
122
|
+
ORDER BY
|
123
|
+
rank0 DESC,
|
124
|
+
rank1 DESC,
|
125
|
+
rank2 DESC
|
126
|
+
SQL
|
127
|
+
end
|
128
|
+
|
129
|
+
end
|
@@ -0,0 +1,128 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class SynonymTest < ActiveSupport::TestCase
|
4
|
+
|
5
|
+
test '::synonyms' do
|
6
|
+
Runestone.add_synonyms({
|
7
|
+
'17' => %w(17th seventeen seventeenth),
|
8
|
+
'spruce' => %w(pine)
|
9
|
+
})
|
10
|
+
|
11
|
+
query = Runestone::Model.search('17 spruce')
|
12
|
+
|
13
|
+
assert_sql(<<~SQL, query.to_sql)
|
14
|
+
SELECT
|
15
|
+
"runestones".*,
|
16
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '17 & spruce:*')) AS rank0,
|
17
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '(17 | 17th | seventeen | seventeenth) & (spruce:* | pine)')) AS rank1
|
18
|
+
FROM "runestones"
|
19
|
+
WHERE
|
20
|
+
"runestones"."vector" @@ to_tsquery('simple_unaccent', '(17 | 17th | seventeen | seventeenth) & (spruce:* | pine)')
|
21
|
+
ORDER BY rank0 DESC, rank1 DESC
|
22
|
+
SQL
|
23
|
+
end
|
24
|
+
|
25
|
+
test '::synonyms expanded to two words' do
|
26
|
+
Runestone.add_synonyms({
|
27
|
+
'supernovae' => ['super novae']
|
28
|
+
})
|
29
|
+
|
30
|
+
assert_equal "(supernovae:* | (super <1> novae))", Runestone::WebSearch.parse('supernovae').synonymize.to_s
|
31
|
+
end
|
32
|
+
|
33
|
+
test '::synonyms are evaluated in lowercase' do
|
34
|
+
Runestone.add_synonyms({
|
35
|
+
'17' => %w(17th seventeen seventeenth),
|
36
|
+
'spruce' => %w(pine)
|
37
|
+
})
|
38
|
+
query = Runestone::Model.search('17 Spruce')
|
39
|
+
|
40
|
+
assert_sql(<<~SQL, query.to_sql)
|
41
|
+
SELECT
|
42
|
+
"runestones".*,
|
43
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '17 & spruce:*')) AS rank0,
|
44
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '(17 | 17th | seventeen | seventeenth) & (spruce:* | pine)')) AS rank1
|
45
|
+
FROM "runestones"
|
46
|
+
WHERE
|
47
|
+
"runestones"."vector" @@ to_tsquery('simple_unaccent', '(17 | 17th | seventeen | seventeenth) & (spruce:* | pine)')
|
48
|
+
ORDER BY rank0 DESC, rank1 DESC
|
49
|
+
SQL
|
50
|
+
end
|
51
|
+
|
52
|
+
test '::not with synonyms' do
|
53
|
+
Runestone.add_synonyms({
|
54
|
+
'17' => %w(17th seventeen seventeenth),
|
55
|
+
'spruce' => %w(pine)
|
56
|
+
})
|
57
|
+
query = Runestone::Model.search('17 -spruce')
|
58
|
+
|
59
|
+
assert_sql(<<~SQL, query.to_sql)
|
60
|
+
SELECT
|
61
|
+
"runestones".*,
|
62
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '17 & !spruce')) AS rank0,
|
63
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '(17 | 17th | seventeen | seventeenth) & !spruce')) AS rank1
|
64
|
+
FROM "runestones"
|
65
|
+
WHERE
|
66
|
+
"runestones"."vector" @@ to_tsquery('simple_unaccent', '(17 | 17th | seventeen | seventeenth) & !spruce')
|
67
|
+
ORDER BY rank0 DESC, rank1 DESC
|
68
|
+
SQL
|
69
|
+
end
|
70
|
+
|
71
|
+
test '::synonym in quotes' do
|
72
|
+
Runestone.add_synonyms({
|
73
|
+
'17' => %w(17th seventeen seventeenth),
|
74
|
+
'spruce' => %w(pine)
|
75
|
+
})
|
76
|
+
query = Runestone::Model.search('17 "spruce"')
|
77
|
+
|
78
|
+
assert_sql(<<~SQL, query.to_sql)
|
79
|
+
SELECT
|
80
|
+
"runestones".*,
|
81
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '17 & spruce')) AS rank0,
|
82
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '(17 | 17th | seventeen | seventeenth) & spruce')) AS rank1
|
83
|
+
FROM "runestones"
|
84
|
+
WHERE
|
85
|
+
"runestones"."vector" @@ to_tsquery('simple_unaccent', '(17 | 17th | seventeen | seventeenth) & spruce')
|
86
|
+
ORDER BY rank0 DESC, rank1 DESC
|
87
|
+
SQL
|
88
|
+
end
|
89
|
+
|
90
|
+
test '::synonym expanded for misspellings' do
|
91
|
+
Runestone::Corpus.add(*%w{17 seventeen spruce pine plne})
|
92
|
+
Runestone.add_synonyms({
|
93
|
+
'17' => %w(17th seventeen seventeenth),
|
94
|
+
'spruce' => %w(pine)
|
95
|
+
})
|
96
|
+
query = Runestone::Model.search('17 spruce')
|
97
|
+
|
98
|
+
assert_sql(<<~SQL, query.to_sql)
|
99
|
+
SELECT
|
100
|
+
"runestones".*,
|
101
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '17 & spruce:*')) AS rank0,
|
102
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '(17 | 17th | seventeen | seventeenth) & (spruce:* | pine)')) AS rank1
|
103
|
+
FROM "runestones"
|
104
|
+
WHERE
|
105
|
+
"runestones"."vector" @@ to_tsquery('simple_unaccent', '(17 | 17th | seventeen | seventeenth) & (spruce:* | pine)')
|
106
|
+
ORDER BY rank0 DESC, rank1 DESC
|
107
|
+
SQL
|
108
|
+
end
|
109
|
+
|
110
|
+
test '::synonym phrase substitution' do
|
111
|
+
Runestone.add_synonyms({
|
112
|
+
'one hundred' => ['100', 'one hundy']
|
113
|
+
})
|
114
|
+
query = Runestone::Model.search('one hundred spruce')
|
115
|
+
|
116
|
+
assert_sql(<<~SQL, query.to_sql)
|
117
|
+
SELECT
|
118
|
+
"runestones".*,
|
119
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', 'one & hundred & spruce:*')) AS rank0,
|
120
|
+
ts_rank_cd("runestones"."vector", to_tsquery('simple_unaccent', '((one & hundred | 100) & spruce:* | (one & hundred | one hundy) & spruce:*)')) AS rank1
|
121
|
+
FROM "runestones"
|
122
|
+
WHERE
|
123
|
+
"runestones"."vector" @@ to_tsquery('simple_unaccent', '((one & hundred | 100) & spruce:* | (one & hundred | one hundy) & spruce:*)')
|
124
|
+
ORDER BY rank0 DESC, rank1 DESC
|
125
|
+
SQL
|
126
|
+
end
|
127
|
+
|
128
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,185 @@
|
|
1
|
+
# To make testing/debugging easier, test within this source tree versus an
|
2
|
+
# installed gem
|
3
|
+
$LOAD_PATH << File.expand_path('../lib', __FILE__)
|
4
|
+
|
5
|
+
require 'simplecov'
|
6
|
+
SimpleCov.start do
|
7
|
+
add_filter %r{^/test/}
|
8
|
+
# add_group 'lib', 'sunstone/lib'
|
9
|
+
# add_group 'ext', 'sunstone/ext'
|
10
|
+
end
|
11
|
+
|
12
|
+
require "minitest/autorun"
|
13
|
+
require 'minitest/unit'
|
14
|
+
require 'minitest/reporters'
|
15
|
+
require 'faker'
|
16
|
+
require 'byebug'
|
17
|
+
|
18
|
+
require 'active_record'
|
19
|
+
require 'active_job'
|
20
|
+
require 'active_job/test_helper'
|
21
|
+
ActiveJob::Base.queue_adapter = :test
|
22
|
+
require 'runestone'
|
23
|
+
|
24
|
+
# Setup the test db
|
25
|
+
ActiveSupport.test_order = :random
|
26
|
+
require File.expand_path('../database', __FILE__)
|
27
|
+
|
28
|
+
Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new
|
29
|
+
|
30
|
+
$debugging = false
|
31
|
+
|
32
|
+
# File 'lib/active_support/testing/declarative.rb', somewhere in rails....
|
33
|
+
class ActiveSupport::TestCase
|
34
|
+
|
35
|
+
include ActiveJob::TestHelper
|
36
|
+
|
37
|
+
# File 'lib/active_support/testing/declarative.rb'
|
38
|
+
def self.test(name, &block)
|
39
|
+
test_name = "test_#{name.gsub(/\s+/, '_')}".to_sym
|
40
|
+
defined = method_defined? test_name
|
41
|
+
raise "#{test_name} is already defined in #{self}" if defined
|
42
|
+
if block_given?
|
43
|
+
define_method(test_name, &block)
|
44
|
+
else
|
45
|
+
define_method(test_name) do
|
46
|
+
skip "No implementation provided for #{name}"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def teardown
|
52
|
+
super
|
53
|
+
Runestone.synonyms.clear
|
54
|
+
Runestone::Model.connection.execute('DELETE FROM runestone_corpus')
|
55
|
+
ActiveRecord::Base.subclasses.reject{|k| k.name.start_with?('ActiveRecord') }.each(&:delete_all)
|
56
|
+
end
|
57
|
+
|
58
|
+
def debug
|
59
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
60
|
+
$debugging = true
|
61
|
+
yield
|
62
|
+
ensure
|
63
|
+
ActiveRecord::Base.logger = nil
|
64
|
+
$debugging = false
|
65
|
+
end
|
66
|
+
|
67
|
+
def capture_sql
|
68
|
+
# ActiveRecord::Base.connection.materialize_transactions
|
69
|
+
SQLLogger.clear_log
|
70
|
+
yield
|
71
|
+
SQLLogger.log_all.dup
|
72
|
+
end
|
73
|
+
|
74
|
+
def assert_sql(*patterns_to_match)
|
75
|
+
if patterns_to_match.all? { |s| s.is_a?(String) }
|
76
|
+
assert_equal(*patterns_to_match.take(2).map { |sql| sql.gsub(/( +|\n\s*|\s+)/, ' ').strip })
|
77
|
+
else
|
78
|
+
begin
|
79
|
+
ret_value = nil
|
80
|
+
capture_sql { ret_value = yield }
|
81
|
+
ret_value
|
82
|
+
ensure
|
83
|
+
failed_patterns = []
|
84
|
+
patterns_to_match.each do |pattern|
|
85
|
+
failed_patterns << pattern unless SQLLogger.log_all.any?{ |sql| pattern === sql }
|
86
|
+
end
|
87
|
+
assert failed_patterns.empty?, "Query pattern(s) #{failed_patterns.map(&:inspect).join(', ')} not found.#{SQLLogger.log.size == 0 ? '' : "\nQueries:\n#{SQLLogger.log.join("\n")}"}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def assert_no_sql(*patterns_to_match)
|
93
|
+
if patterns_to_match.all? { |s| s.is_a?(String) }
|
94
|
+
assert_not_equal(*patterns_to_match.take(2).map { |sql| sql.gsub(/( +|\n\s*|\s+)/, ' ').strip })
|
95
|
+
else
|
96
|
+
begin
|
97
|
+
ret_value = nil
|
98
|
+
capture_sql { ret_value = yield }
|
99
|
+
ret_value
|
100
|
+
ensure
|
101
|
+
failed_patterns = []
|
102
|
+
patterns_to_match.each do |pattern|
|
103
|
+
failed_patterns << pattern unless SQLLogger.log_all.any?{ |sql| pattern === sql }
|
104
|
+
end
|
105
|
+
assert !failed_patterns.empty?, "Query pattern(s) #{failed_patterns.map(&:inspect).join(', ')} found.#{SQLLogger.log.size == 0 ? '' : "\nQueries:\n#{SQLLogger.log.join("\n")}"}"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def corpus
|
111
|
+
Runestone::Model.connection.execute('SELECT word FROM runestone_corpus ORDER BY word').values.flatten
|
112
|
+
end
|
113
|
+
|
114
|
+
def assert_corpus(*words)
|
115
|
+
assert_equal words.flatten.sort, corpus
|
116
|
+
end
|
117
|
+
|
118
|
+
def assert_corpus_has(*words)
|
119
|
+
assert_equal 0, (words - corpus).size
|
120
|
+
end
|
121
|
+
|
122
|
+
class SQLLogger
|
123
|
+
class << self
|
124
|
+
attr_accessor :ignored_sql, :log, :log_all
|
125
|
+
def clear_log; self.log = []; self.log_all = []; end
|
126
|
+
end
|
127
|
+
|
128
|
+
self.clear_log
|
129
|
+
|
130
|
+
self.ignored_sql = [/^PRAGMA/i, /^SELECT currval/i, /^SELECT CAST/i, /^SELECT @@IDENTITY/i, /^SELECT @@ROWCOUNT/i, /^SAVEPOINT/i, /^ROLLBACK TO SAVEPOINT/i, /^RELEASE SAVEPOINT/i, /^SHOW max_identifier_length/i, /^BEGIN/i, /^COMMIT/i]
|
131
|
+
|
132
|
+
# FIXME: this needs to be refactored so specific database can add their own
|
133
|
+
# ignored SQL, or better yet, use a different notification for the queries
|
134
|
+
# instead examining the SQL content.
|
135
|
+
oracle_ignored = [/^select .*nextval/i, /^SAVEPOINT/, /^ROLLBACK TO/, /^\s*select .* from all_triggers/im, /^\s*select .* from all_constraints/im, /^\s*select .* from all_tab_cols/im]
|
136
|
+
mysql_ignored = [/^SHOW FULL TABLES/i, /^SHOW FULL FIELDS/, /^SHOW CREATE TABLE /i, /^SHOW VARIABLES /, /^\s*SELECT (?:column_name|table_name)\b.*\bFROM information_schema\.(?:key_column_usage|tables)\b/im]
|
137
|
+
postgresql_ignored = [/^\s*select\b.*\bfrom\b.*pg_namespace\b/im, /^\s*select tablename\b.*from pg_tables\b/im, /^\s*select\b.*\battname\b.*\bfrom\b.*\bpg_attribute\b/im, /^SHOW search_path/i]
|
138
|
+
sqlite3_ignored = [/^\s*SELECT name\b.*\bFROM sqlite_master/im, /^\s*SELECT sql\b.*\bFROM sqlite_master/im]
|
139
|
+
|
140
|
+
[oracle_ignored, mysql_ignored, postgresql_ignored, sqlite3_ignored].each do |db_ignored_sql|
|
141
|
+
ignored_sql.concat db_ignored_sql
|
142
|
+
end
|
143
|
+
|
144
|
+
attr_reader :ignore
|
145
|
+
|
146
|
+
def initialize(ignore = Regexp.union(self.class.ignored_sql))
|
147
|
+
@ignore = ignore
|
148
|
+
end
|
149
|
+
|
150
|
+
def call(name, start, finish, message_id, values)
|
151
|
+
sql = values[:sql]
|
152
|
+
|
153
|
+
# FIXME: this seems bad. we should probably have a better way to indicate
|
154
|
+
# the query was cached
|
155
|
+
return if 'CACHE' == values[:name]
|
156
|
+
|
157
|
+
self.class.log_all << sql
|
158
|
+
unless ignore =~ sql
|
159
|
+
if $debugging
|
160
|
+
puts caller.select { |l| l.starts_with?(File.expand_path('../../lib', __FILE__)) }
|
161
|
+
puts "\n\n"
|
162
|
+
end
|
163
|
+
end
|
164
|
+
self.class.log << sql unless ignore =~ sql
|
165
|
+
end
|
166
|
+
end
|
167
|
+
ActiveSupport::Notifications.subscribe('sql.active_record', SQLLogger.new)
|
168
|
+
|
169
|
+
# test/unit backwards compatibility methods
|
170
|
+
alias :assert_raise :assert_raises
|
171
|
+
alias :assert_not_empty :refute_empty
|
172
|
+
alias :assert_not_equal :refute_equal
|
173
|
+
alias :assert_not_in_delta :refute_in_delta
|
174
|
+
alias :assert_not_in_epsilon :refute_in_epsilon
|
175
|
+
alias :assert_not_includes :refute_includes
|
176
|
+
alias :assert_not_instance_of :refute_instance_of
|
177
|
+
alias :assert_not_kind_of :refute_kind_of
|
178
|
+
alias :assert_no_match :refute_match
|
179
|
+
alias :assert_not_nil :refute_nil
|
180
|
+
alias :assert_not_operator :refute_operator
|
181
|
+
alias :assert_not_predicate :refute_predicate
|
182
|
+
alias :assert_not_respond_to :refute_respond_to
|
183
|
+
alias :assert_not_same :refute_same
|
184
|
+
|
185
|
+
end
|