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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21bf6d369ed9516908e5ac52bb3b5688335e05952e920741cccd40d73b2279d8
4
- data.tar.gz: 7257fa7063ad0b250c7d3b602a7acc5bc3b42984b6fefa47202629eedbb03018
3
+ metadata.gz: babc73df6dc70b27a5f6ae5c0adb98af6adfaa5080ba9072800002d3102af646
4
+ data.tar.gz: 6dc3742766a2f8d737c6f045741fc1981a0a538ca269bfae11ab28ca4d8897b2
5
5
  SHA512:
6
- metadata.gz: 8995e6d99585aadc2add2e47b9d18829313dd29853468c706e0297a4361b633acbe593db5fcd34e0a2890cc1832f4758d95f74d3a10ad990fde0dc41e91f348b
7
- data.tar.gz: c6e75132f957e688b23ef5499fc8af50189edeb470ed664ad3dab4ca5096a3a343e7e33902d98fb0db2113b7993610b8126ee20653bac026787c7c3397c9d380
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.1.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.8)
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.9.1)
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.1)
87
+ mini_portile2 (2.5.3)
88
88
  minitest (5.14.4)
89
89
  nio4r (2.5.7)
90
- nokogiri (1.11.4)
90
+ nokogiri (1.11.7)
91
91
  mini_portile2 (~> 2.5.0)
92
92
  racc (~> 1.4)
93
- nokogiri (1.11.4-arm64-darwin)
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.5.0)
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.3)
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
+ [![Lines of Code](http://img.shields.io/badge/lines_of_code-237-brightgreen.svg?style=flat)](http://blog.codinghorror.com/the-best-code-is-no-code-at-all/)
2
+
1
3
  # Polysearch
2
4
 
3
- #### Simplified polymorphic full text + similarity search based on postgres
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
- .then { |list| list << make_tsvector(first_name, weight: "A") }
48
- .then { |list| list << make_tsvector(last_name, weight: "A") }
49
- .then { |list| list << make_tsvector(email, weight: "B") }
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
- 1. Start searching
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
- User.polysearch("nate")
60
- User.polysearch("ntae") # misspellings also return results
61
- User.polysearch("nate").where(created_at: 1.day.ago..Current.time) # active record chaining
62
- User.polysearch("nate").order(created_at: :desc) # chain additional ordering after the polysearch scope
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 :select_fts_rank, ->(value, *selects, rank_alias: nil) {
37
- value = value.to_s.gsub(/\W/, " ").squeeze(" ").downcase.strip
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(rank_alias)
45
- select(*selects).order("#{rank_alias} desc")
40
+ selects << ts_rank.as("search_rank")
41
+ select(*selects).reorder("search_rank desc")
46
42
  }
47
43
 
48
- scope :fts, ->(value) {
49
- value = value.to_s.gsub(/\W/, " ").squeeze(" ").downcase.strip
50
- value = Arel::Nodes::SqlLiteral.new(sanitize_sql_array(["?", value.to_s]))
51
- plainto_tsquery = Arel::Nodes::NamedFunction.new("plainto_tsquery", [Arel::Nodes::SqlLiteral.new("'simple'"), value])
52
- where(Arel::Nodes::InfixOperation.new("@@", arel_table[:value], plainto_tsquery))
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, rank_alias: nil) {
56
- value = value.to_s.gsub(/\W/, " ").squeeze(" ").downcase.strip
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 << Arel::Nodes::NamedFunction.new("similarity", [arel_table[:words], value]).as(rank_alias)
62
- select(*selects).order("#{rank_alias} desc")
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 :similar, ->(value, range: 0.01) {
66
- value = value.to_s.gsub(/\W/, " ").squeeze(" ").downcase.strip
67
- value = Arel::Nodes::SqlLiteral.new(sanitize_sql_array(["?", value.to_s]))
68
- where Arel::Nodes::NamedFunction.new("similarity", [arel_table[:words], value]).gteq(range)
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
- if value.blank?
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
- fts = Polysearch::Record.where(searchable: self).first_or_create
70
- fts.update_value tsvector
71
- fts.update_columns words: similarity_words.join(" ")
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
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+
3
+ cloc --exclude-dir=test --include-ext=rb .
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/concerns/searchable"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Polysearch
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.4"
5
5
  end
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.1.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-05-18 00:00:00.000000000 Z
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/concerns/searchable.rb
96
- - app/models/record.rb
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