runestone 1.0
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/.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
|