polysearch 0.1.0 → 0.2.4

Sign up to get free protection for your applications and to get access to all the features.
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