polysearch 0.1.0 → 0.2.4
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 +4 -4
- data/Gemfile.lock +8 -8
- data/README.md +35 -11
- data/app/models/{record.rb → polysearch/record.rb} +46 -24
- data/app/models/{concerns → polysearch}/searchable.rb +39 -24
- data/bin/loc +3 -0
- data/lib/polysearch.rb +2 -2
- data/lib/polysearch/version.rb +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: babc73df6dc70b27a5f6ae5c0adb98af6adfaa5080ba9072800002d3102af646
|
4
|
+
data.tar.gz: 6dc3742766a2f8d737c6f045741fc1981a0a538ca269bfae11ab28ca4d8897b2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fdee3a4765bd4d4ca85671355a84c2f71336a6ef0d7d31bfc1287975e384f0b42148a383c418a13eddade4d2681c80a67c996ba9eed83b73aaad1dc72e764b84
|
7
|
+
data.tar.gz: efc6a17835a5400d5220e434ff6ca332d2ce8a0aa09d0ed2a55fd772da8ea97546389f2fe1be3e4243091b02012d243e26ecd62143f75b11633c774071715568
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
polysearch (0.
|
4
|
+
polysearch (0.2.4)
|
5
5
|
rails (>= 6.0)
|
6
6
|
|
7
7
|
GEM
|
@@ -68,14 +68,14 @@ GEM
|
|
68
68
|
zeitwerk (~> 2.3)
|
69
69
|
ast (2.4.2)
|
70
70
|
builder (3.2.4)
|
71
|
-
concurrent-ruby (1.1.
|
71
|
+
concurrent-ruby (1.1.9)
|
72
72
|
crass (1.0.6)
|
73
73
|
erubi (1.10.0)
|
74
74
|
globalid (0.4.2)
|
75
75
|
activesupport (>= 4.2.0)
|
76
76
|
i18n (1.8.10)
|
77
77
|
concurrent-ruby (~> 1.0)
|
78
|
-
loofah (2.
|
78
|
+
loofah (2.10.0)
|
79
79
|
crass (~> 1.0.2)
|
80
80
|
nokogiri (>= 1.5.9)
|
81
81
|
magic_frozen_string_literal (1.2.0)
|
@@ -84,13 +84,13 @@ GEM
|
|
84
84
|
marcel (1.0.1)
|
85
85
|
method_source (1.0.0)
|
86
86
|
mini_mime (1.0.3)
|
87
|
-
mini_portile2 (2.5.
|
87
|
+
mini_portile2 (2.5.3)
|
88
88
|
minitest (5.14.4)
|
89
89
|
nio4r (2.5.7)
|
90
|
-
nokogiri (1.11.
|
90
|
+
nokogiri (1.11.7)
|
91
91
|
mini_portile2 (~> 2.5.0)
|
92
92
|
racc (~> 1.4)
|
93
|
-
nokogiri (1.11.
|
93
|
+
nokogiri (1.11.7-arm64-darwin)
|
94
94
|
racc (~> 1.4)
|
95
95
|
parallel (1.20.1)
|
96
96
|
parser (3.0.1.1)
|
@@ -138,7 +138,7 @@ GEM
|
|
138
138
|
rubocop-ast (>= 1.5.0, < 2.0)
|
139
139
|
ruby-progressbar (~> 1.7)
|
140
140
|
unicode-display_width (>= 1.4.0, < 3.0)
|
141
|
-
rubocop-ast (1.
|
141
|
+
rubocop-ast (1.7.0)
|
142
142
|
parser (>= 3.0.1.1)
|
143
143
|
rubocop-performance (1.11.2)
|
144
144
|
rubocop (>= 1.7.0, < 2.0)
|
@@ -160,7 +160,7 @@ GEM
|
|
160
160
|
tzinfo (2.0.4)
|
161
161
|
concurrent-ruby (~> 1.0)
|
162
162
|
unicode-display_width (2.0.0)
|
163
|
-
websocket-driver (0.7.
|
163
|
+
websocket-driver (0.7.5)
|
164
164
|
websocket-extensions (>= 0.1.0)
|
165
165
|
websocket-extensions (0.1.5)
|
166
166
|
zeitwerk (2.4.2)
|
data/README.md
CHANGED
@@ -1,6 +1,10 @@
|
|
1
|
+
[](http://blog.codinghorror.com/the-best-code-is-no-code-at-all/)
|
2
|
+
|
1
3
|
# Polysearch
|
2
4
|
|
3
|
-
|
5
|
+
Simplified polymorphic full text + similarity search based on postgres.
|
6
|
+
|
7
|
+
> NOTE: This project is narrower in scope and more opinionated than [pg_search](https://github.com/Casecommons/pg_search).
|
4
8
|
|
5
9
|
## Requirements
|
6
10
|
|
@@ -43,23 +47,43 @@
|
|
43
47
|
after_save_commit :update_polysearch
|
44
48
|
|
45
49
|
def to_tsvectors
|
46
|
-
[
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
+
[
|
51
|
+
make_tsvector(first_name, weight: "A"),
|
52
|
+
make_tsvector(last_name, weight: "A"),
|
53
|
+
make_tsvector(nickname, weight: "B")
|
54
|
+
]
|
50
55
|
end
|
51
56
|
end
|
52
57
|
```
|
53
58
|
|
54
|
-
|
59
|
+
If you have existing records that need to create/update a polysearch record, you can save them like this.
|
55
60
|
|
61
|
+
```ruby
|
62
|
+
User.find_each(&:update_polysearch)
|
56
63
|
```
|
57
|
-
User.create first_name: "Nate", last_name: "Hopkins", email: "nhopkins@mailinator.com"
|
58
64
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
User.
|
65
|
+
1. Start searching
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
User.create first_name: "Shawn", last_name: "Spencer", nickname: "Maverick"
|
69
|
+
|
70
|
+
# find natural language matches (faster)
|
71
|
+
User.full_text_search("shawn")
|
72
|
+
|
73
|
+
# find similarity matches, best for misspelled search terms (slower)
|
74
|
+
User.similarity_search("shwn")
|
75
|
+
|
76
|
+
# perform both a full text search and similarity search
|
77
|
+
User.combined_search("shwn")
|
78
|
+
|
79
|
+
# perform a full text search and fall back to similarity search (faster than combined_search)
|
80
|
+
User.polysearch("shwn")
|
81
|
+
|
82
|
+
# calculate counts (explicitly pass :id to omit search rankings)
|
83
|
+
User.full_text_search("shawn").count(:id)
|
84
|
+
User.similarity_search("shwn").count(:id)
|
85
|
+
User.combined_search("shwn").count(:id)
|
86
|
+
User.polysearch("shwn").count(:id)
|
63
87
|
```
|
64
88
|
|
65
89
|
## License
|
@@ -33,39 +33,57 @@ module Polysearch
|
|
33
33
|
|
34
34
|
# scopes ....................................................................
|
35
35
|
|
36
|
-
scope :
|
37
|
-
|
38
|
-
value = Arel::Nodes::SqlLiteral.new(sanitize_sql_array(["?", value.to_s]))
|
39
|
-
plainto_tsquery = Arel::Nodes::NamedFunction.new("plainto_tsquery", [Arel::Nodes::SqlLiteral.new("'simple'"), value])
|
36
|
+
scope :select_full_text_search_rank, ->(value, *selects) {
|
37
|
+
plainto_tsquery = Arel::Nodes::NamedFunction.new("plainto_tsquery", [Arel::Nodes::SqlLiteral.new("'simple'"), arel_search_value(value)])
|
40
38
|
ts_rank = Arel::Nodes::NamedFunction.new("ts_rank", [arel_table[:value], plainto_tsquery])
|
41
|
-
|
42
|
-
rank_alias ||= "fts_rank"
|
43
39
|
selects << Arel.star if selects.blank?
|
44
|
-
selects << ts_rank.as(
|
45
|
-
select(*selects).
|
40
|
+
selects << ts_rank.as("search_rank")
|
41
|
+
select(*selects).reorder("search_rank desc")
|
46
42
|
}
|
47
43
|
|
48
|
-
scope :
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
44
|
+
scope :full_text_search, ->(value) {
|
45
|
+
if value.blank?
|
46
|
+
all
|
47
|
+
else
|
48
|
+
plainto_tsquery = Arel::Nodes::NamedFunction.new("plainto_tsquery", [Arel::Nodes::SqlLiteral.new("'simple'"), arel_search_value(value)])
|
49
|
+
where(Arel::Nodes::InfixOperation.new("@@", arel_table[:value], plainto_tsquery))
|
50
|
+
end
|
53
51
|
}
|
54
52
|
|
55
|
-
scope :select_similarity_rank, ->(value, *selects
|
56
|
-
|
57
|
-
value = Arel::Nodes::SqlLiteral.new(sanitize_sql_array(["?", value.to_s]))
|
58
|
-
|
59
|
-
rank_alias ||= "similarity_rank"
|
53
|
+
scope :select_similarity_rank, ->(value, *selects) {
|
54
|
+
similarity = Arel::Nodes::NamedFunction.new("similarity", [arel_table[:words], arel_search_value(value)])
|
60
55
|
selects << Arel.star if selects.blank?
|
61
|
-
selects <<
|
62
|
-
select(*selects).order("
|
56
|
+
selects << similarity.as("search_rank")
|
57
|
+
select(*selects).order("search_rank desc")
|
58
|
+
}
|
59
|
+
|
60
|
+
scope :similarity_search, ->(value) {
|
61
|
+
if value.blank?
|
62
|
+
all
|
63
|
+
else
|
64
|
+
where Arel::Nodes::NamedFunction.new("similarity", [arel_table[:words], arel_search_value(value)]).gt(0)
|
65
|
+
end
|
66
|
+
}
|
67
|
+
|
68
|
+
scope :combined_search, ->(value) {
|
69
|
+
subquery = <<~SQL
|
70
|
+
(
|
71
|
+
#{select_full_text_search_rank(value).full_text_search(value).except(:order).to_sql}
|
72
|
+
UNION ALL
|
73
|
+
#{select_similarity_rank(value).similarity_search(value).except(:order).to_sql}
|
74
|
+
) AS #{table_name}
|
75
|
+
SQL
|
76
|
+
from(subquery).order("search_rank desc")
|
63
77
|
}
|
64
78
|
|
65
|
-
scope :
|
66
|
-
|
67
|
-
|
68
|
-
|
79
|
+
scope :polysearch, ->(value) {
|
80
|
+
if value.blank?
|
81
|
+
all
|
82
|
+
else
|
83
|
+
full_text_search(value).exists? ?
|
84
|
+
select_full_text_search_rank(value).full_text_search(value) :
|
85
|
+
select_similarity_rank(value).similarity_search(value)
|
86
|
+
end
|
69
87
|
}
|
70
88
|
|
71
89
|
# additional config (i.e. accepts_nested_attribute_for etc...) ..............
|
@@ -74,6 +92,10 @@ module Polysearch
|
|
74
92
|
|
75
93
|
# class methods .............................................................
|
76
94
|
class << self
|
95
|
+
def arel_search_value(value)
|
96
|
+
value = value.to_s.gsub(/\W/, " ").squeeze(" ").downcase.strip
|
97
|
+
Arel::Nodes::SqlLiteral.new(sanitize_sql_array(["?", value]))
|
98
|
+
end
|
77
99
|
end
|
78
100
|
|
79
101
|
# public instance methods ...................................................
|
@@ -36,39 +36,54 @@ module Polysearch
|
|
36
36
|
has_one :polysearch, as: :searchable, class_name: "Polysearch::Record", inverse_of: "searchable"
|
37
37
|
after_destroy :destroy_polysearch
|
38
38
|
|
39
|
+
scope :full_text_search, ->(value) {
|
40
|
+
value.blank? ? all : joins(:polysearch).merge(Polysearch::Record.full_text_search(value).select_full_text_search_rank(value))
|
41
|
+
}
|
42
|
+
|
43
|
+
scope :similarity_search, ->(value) {
|
44
|
+
value.blank? ? all : joins(:polysearch).merge(Polysearch::Record.similarity_search(value).select_similarity_rank(value))
|
45
|
+
}
|
46
|
+
|
47
|
+
scope :combined_search, ->(value) {
|
48
|
+
subquery = <<~SQL
|
49
|
+
(
|
50
|
+
SELECT #{table_name}.*, searchable_polysearches.search_rank from (#{Polysearch::Record.combined_search(value).except(:order).to_sql}) searchable_polysearches
|
51
|
+
LEFT JOIN LATERAL (select * from #{table_name} WHERE id = searchable_polysearches.searchable_id) #{table_name} ON TRUE
|
52
|
+
) #{table_name}
|
53
|
+
SQL
|
54
|
+
from(subquery).order("search_rank desc")
|
55
|
+
}
|
56
|
+
|
39
57
|
scope :polysearch, ->(value) {
|
40
|
-
|
41
|
-
all
|
42
|
-
else
|
43
|
-
fts_rank_alias = "#{table_name.singularize}_fts_rank"
|
44
|
-
similarity_rank_alias = "#{table_name.singularize}_similarity_rank"
|
45
|
-
|
46
|
-
fts = Polysearch::Record
|
47
|
-
.select_fts_rank(value, :searchable_id, rank_alias: fts_rank_alias)
|
48
|
-
.select_similarity_rank(value, :searchable_id, rank_alias: similarity_rank_alias)
|
49
|
-
.where(searchable_type: name)
|
50
|
-
.fts(value).or(Polysearch::Record.similar(value))
|
51
|
-
|
52
|
-
query = <<~SQL
|
53
|
-
SELECT searchables.*, fts.#{fts_rank_alias}, fts.#{similarity_rank_alias} from (#{fts.to_sql}) fts
|
54
|
-
LEFT JOIN LATERAL (select * from #{table_name} WHERE id = fts.searchable_id) searchables ON TRUE
|
55
|
-
SQL
|
56
|
-
|
57
|
-
select(Arel.star).from(Arel::Nodes::SqlLiteral.new("(#{query})").as(table_name))
|
58
|
-
.reorder(fts_rank_alias => :desc, similarity_rank_alias => :desc)
|
59
|
-
end
|
58
|
+
value.blank? ? all : joins(:polysearch).merge(Polysearch::Record.polysearch(value))
|
60
59
|
}
|
61
60
|
end
|
62
61
|
|
63
62
|
def update_polysearch
|
63
|
+
return unless persisted?
|
64
64
|
tsvectors = to_tsvectors.compact.uniq
|
65
65
|
return if tsvectors.blank?
|
66
|
+
|
66
67
|
tsvectors.pop while tsvectors.size > 500
|
67
68
|
tsvectors.concat similarity_words_tsvectors
|
68
69
|
tsvector = tsvectors.join(" || ")
|
69
|
-
|
70
|
-
|
71
|
-
|
70
|
+
|
71
|
+
attributes = {
|
72
|
+
searchable_type: self.class.name,
|
73
|
+
searchable_id: id,
|
74
|
+
words: similarity_words.join(" "),
|
75
|
+
created_at: Time.current,
|
76
|
+
updated_at: Time.current
|
77
|
+
}
|
78
|
+
|
79
|
+
result = Polysearch::Record.upsert(
|
80
|
+
attributes,
|
81
|
+
unique_by: [:searchable_type, :searchable_id],
|
82
|
+
returning: :id
|
83
|
+
)
|
84
|
+
|
85
|
+
record = Polysearch::Record.find_by(id: result.first["id"])
|
86
|
+
record.update_value tsvector
|
72
87
|
end
|
73
88
|
|
74
89
|
# Polysearch::Searchable#to_tsvectors is abstract... a noop by default
|
@@ -95,7 +110,7 @@ module Polysearch
|
|
95
110
|
Arel::Nodes::SqlLiteral.new(sanitize_sql_value("SELECT #{tsvector}"))
|
96
111
|
])
|
97
112
|
length = Arel::Nodes::NamedFunction.new("length", [Arel::Nodes::SqlLiteral.new(quote_column_name(:word))])
|
98
|
-
query = self.class.select(:word).from(ts_stat.to_sql).where(length.gteq(3)).to_sql
|
113
|
+
query = self.class.unscoped.select(:word).from(ts_stat.to_sql).where(length.gteq(3)).to_sql
|
99
114
|
result = self.class.connection.execute(query)
|
100
115
|
result.values.flatten
|
101
116
|
end
|
data/bin/loc
ADDED
data/lib/polysearch.rb
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "polysearch/version"
|
4
|
-
require_relative "../app/models/record"
|
5
|
-
require_relative "../app/models/
|
4
|
+
require_relative "../app/models/polysearch/record"
|
5
|
+
require_relative "../app/models/polysearch/searchable"
|
6
6
|
|
7
7
|
module Polysearch
|
8
8
|
class Engine < Rails::Engine
|
data/lib/polysearch/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: polysearch
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nathan Hopkins
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-
|
11
|
+
date: 2021-06-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -92,9 +92,10 @@ files:
|
|
92
92
|
- LICENSE.txt
|
93
93
|
- README.md
|
94
94
|
- Rakefile
|
95
|
-
- app/models/
|
96
|
-
- app/models/
|
95
|
+
- app/models/polysearch/record.rb
|
96
|
+
- app/models/polysearch/searchable.rb
|
97
97
|
- bin/console
|
98
|
+
- bin/loc
|
98
99
|
- bin/setup
|
99
100
|
- bin/standardize
|
100
101
|
- lib/generators/polysearch/migration_generator.rb
|