pg-searchable 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: eaf2e17acef704a8fd936d20b9716dace4e9f4319a26f8d72e4f4a79fa515bde
4
+ data.tar.gz: 8153451549b6fd494227f9c01377b0021241026188509778b2c3c430b087fa23
5
+ SHA512:
6
+ metadata.gz: 70964f08dad60214f6d87f7f5f2497ce7118ad92d3ced7df0d79bcf31435bb45d9d269ff096e700d27c840df4b67383435b1ef96978e6d95429756d9d5ab6e99
7
+ data.tar.gz: 4904c0b55c7c847f15a1f2b2097a684a577c3ae28d1380bd9fed5f290db3fc899e5b2e8fde7f26212e5a13921a1fb05228213e272683589ccf15e05718d157a3
data/.editorconfig ADDED
@@ -0,0 +1,5 @@
1
+ root = true
2
+
3
+ [**.{rb,md}]
4
+ indent_size = 2
5
+ indent_style = space
@@ -0,0 +1,32 @@
1
+ name: Test
2
+
3
+ on: [push]
4
+
5
+ jobs:
6
+ rspec-test:
7
+ name: RSpec
8
+ runs-on: ubuntu-18.04
9
+ services:
10
+ postgres:
11
+ image: postgres:12
12
+ env:
13
+ POSTGRES_USER: postgres
14
+ POSTGRES_PASSWORD: postgres
15
+ POSTGRES_DB: postgres_test
16
+ ports:
17
+ - 5432:5432
18
+ # needed because the postgres container does not provide a healthcheck
19
+ options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
20
+ steps:
21
+ - uses: actions/checkout@v1
22
+ - uses: actions/setup-ruby@v1
23
+ with:
24
+ ruby-version: 2.5
25
+ - name: Install postgres client
26
+ run: sudo apt-get install libpq-dev
27
+ - name: Install dependencies
28
+ run: |
29
+ gem install bundler
30
+ bundler install
31
+ - name: Run tests
32
+ run: bundle exec rspec
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ spec/log
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ group :development do
4
+ gem 'rake'
5
+ gem 'rspec'
6
+ gem 'rails'
7
+ gem 'rspec-rails'
8
+ gem 'pg'
9
+ gem 'combustion', '~> 1.1'
10
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,167 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ actioncable (6.0.2.2)
5
+ actionpack (= 6.0.2.2)
6
+ nio4r (~> 2.0)
7
+ websocket-driver (>= 0.6.1)
8
+ actionmailbox (6.0.2.2)
9
+ actionpack (= 6.0.2.2)
10
+ activejob (= 6.0.2.2)
11
+ activerecord (= 6.0.2.2)
12
+ activestorage (= 6.0.2.2)
13
+ activesupport (= 6.0.2.2)
14
+ mail (>= 2.7.1)
15
+ actionmailer (6.0.2.2)
16
+ actionpack (= 6.0.2.2)
17
+ actionview (= 6.0.2.2)
18
+ activejob (= 6.0.2.2)
19
+ mail (~> 2.5, >= 2.5.4)
20
+ rails-dom-testing (~> 2.0)
21
+ actionpack (6.0.2.2)
22
+ actionview (= 6.0.2.2)
23
+ activesupport (= 6.0.2.2)
24
+ rack (~> 2.0, >= 2.0.8)
25
+ rack-test (>= 0.6.3)
26
+ rails-dom-testing (~> 2.0)
27
+ rails-html-sanitizer (~> 1.0, >= 1.2.0)
28
+ actiontext (6.0.2.2)
29
+ actionpack (= 6.0.2.2)
30
+ activerecord (= 6.0.2.2)
31
+ activestorage (= 6.0.2.2)
32
+ activesupport (= 6.0.2.2)
33
+ nokogiri (>= 1.8.5)
34
+ actionview (6.0.2.2)
35
+ activesupport (= 6.0.2.2)
36
+ builder (~> 3.1)
37
+ erubi (~> 1.4)
38
+ rails-dom-testing (~> 2.0)
39
+ rails-html-sanitizer (~> 1.1, >= 1.2.0)
40
+ activejob (6.0.2.2)
41
+ activesupport (= 6.0.2.2)
42
+ globalid (>= 0.3.6)
43
+ activemodel (6.0.2.2)
44
+ activesupport (= 6.0.2.2)
45
+ activerecord (6.0.2.2)
46
+ activemodel (= 6.0.2.2)
47
+ activesupport (= 6.0.2.2)
48
+ activestorage (6.0.2.2)
49
+ actionpack (= 6.0.2.2)
50
+ activejob (= 6.0.2.2)
51
+ activerecord (= 6.0.2.2)
52
+ marcel (~> 0.3.1)
53
+ activesupport (6.0.2.2)
54
+ concurrent-ruby (~> 1.0, >= 1.0.2)
55
+ i18n (>= 0.7, < 2)
56
+ minitest (~> 5.1)
57
+ tzinfo (~> 1.1)
58
+ zeitwerk (~> 2.2)
59
+ builder (3.2.4)
60
+ combustion (1.1.2)
61
+ activesupport (>= 3.0.0)
62
+ railties (>= 3.0.0)
63
+ thor (>= 0.14.6)
64
+ concurrent-ruby (1.1.6)
65
+ crass (1.0.6)
66
+ diff-lcs (1.3)
67
+ erubi (1.9.0)
68
+ globalid (0.4.2)
69
+ activesupport (>= 4.2.0)
70
+ i18n (1.8.2)
71
+ concurrent-ruby (~> 1.0)
72
+ loofah (2.4.0)
73
+ crass (~> 1.0.2)
74
+ nokogiri (>= 1.5.9)
75
+ mail (2.7.1)
76
+ mini_mime (>= 0.1.1)
77
+ marcel (0.3.3)
78
+ mimemagic (~> 0.3.2)
79
+ method_source (1.0.0)
80
+ mimemagic (0.3.4)
81
+ mini_mime (1.0.2)
82
+ mini_portile2 (2.4.0)
83
+ minitest (5.14.0)
84
+ nio4r (2.5.2)
85
+ nokogiri (1.10.9)
86
+ mini_portile2 (~> 2.4.0)
87
+ pg (1.2.3)
88
+ rack (2.2.2)
89
+ rack-test (1.1.0)
90
+ rack (>= 1.0, < 3)
91
+ rails (6.0.2.2)
92
+ actioncable (= 6.0.2.2)
93
+ actionmailbox (= 6.0.2.2)
94
+ actionmailer (= 6.0.2.2)
95
+ actionpack (= 6.0.2.2)
96
+ actiontext (= 6.0.2.2)
97
+ actionview (= 6.0.2.2)
98
+ activejob (= 6.0.2.2)
99
+ activemodel (= 6.0.2.2)
100
+ activerecord (= 6.0.2.2)
101
+ activestorage (= 6.0.2.2)
102
+ activesupport (= 6.0.2.2)
103
+ bundler (>= 1.3.0)
104
+ railties (= 6.0.2.2)
105
+ sprockets-rails (>= 2.0.0)
106
+ rails-dom-testing (2.0.3)
107
+ activesupport (>= 4.2.0)
108
+ nokogiri (>= 1.6)
109
+ rails-html-sanitizer (1.3.0)
110
+ loofah (~> 2.3)
111
+ railties (6.0.2.2)
112
+ actionpack (= 6.0.2.2)
113
+ activesupport (= 6.0.2.2)
114
+ method_source
115
+ rake (>= 0.8.7)
116
+ thor (>= 0.20.3, < 2.0)
117
+ rake (13.0.1)
118
+ rspec (3.9.0)
119
+ rspec-core (~> 3.9.0)
120
+ rspec-expectations (~> 3.9.0)
121
+ rspec-mocks (~> 3.9.0)
122
+ rspec-core (3.9.1)
123
+ rspec-support (~> 3.9.1)
124
+ rspec-expectations (3.9.1)
125
+ diff-lcs (>= 1.2.0, < 2.0)
126
+ rspec-support (~> 3.9.0)
127
+ rspec-mocks (3.9.1)
128
+ diff-lcs (>= 1.2.0, < 2.0)
129
+ rspec-support (~> 3.9.0)
130
+ rspec-rails (3.9.1)
131
+ actionpack (>= 3.0)
132
+ activesupport (>= 3.0)
133
+ railties (>= 3.0)
134
+ rspec-core (~> 3.9.0)
135
+ rspec-expectations (~> 3.9.0)
136
+ rspec-mocks (~> 3.9.0)
137
+ rspec-support (~> 3.9.0)
138
+ rspec-support (3.9.2)
139
+ sprockets (4.0.0)
140
+ concurrent-ruby (~> 1.0)
141
+ rack (> 1, < 3)
142
+ sprockets-rails (3.2.1)
143
+ actionpack (>= 4.0)
144
+ activesupport (>= 4.0)
145
+ sprockets (>= 3.0.0)
146
+ thor (1.0.1)
147
+ thread_safe (0.3.6)
148
+ tzinfo (1.2.6)
149
+ thread_safe (~> 0.1)
150
+ websocket-driver (0.7.1)
151
+ websocket-extensions (>= 0.1.0)
152
+ websocket-extensions (0.1.4)
153
+ zeitwerk (2.3.0)
154
+
155
+ PLATFORMS
156
+ ruby
157
+
158
+ DEPENDENCIES
159
+ combustion (~> 1.1)
160
+ pg
161
+ rails
162
+ rake
163
+ rspec
164
+ rspec-rails
165
+
166
+ BUNDLED WITH
167
+ 2.1.2
data/README.md ADDED
@@ -0,0 +1,56 @@
1
+ ## pg-searchable
2
+
3
+ pg-searchable is a quick way to make Postgres columns full-text searchable in Rails models, with support for composing complex OR/AND queries.
4
+
5
+ If all you need is a straightforward search with trigram/multi-column support, [pg_search](https://github.com/Casecommons/pg_search) is probably a better fit.
6
+
7
+ ### Install
8
+
9
+ `gem "pg-searchable"`
10
+
11
+ ### Setup
12
+
13
+ ```ruby
14
+ class Product < ActiveRecord::Base
15
+ include PgSearchable::Model
16
+ searchable_column :name
17
+ end
18
+ ```
19
+
20
+ ### Usage
21
+
22
+ ```ruby
23
+ # Add test data
24
+ sour_bread = Product.create!(name: "Sour bread")
25
+ italian_pasta = Product.create!(name: "Italian pasta")
26
+ rye_bread = Product.create!(name: "Rye bread")
27
+ sushi = Product.create!(name: "Sour Sushi Itamae")
28
+
29
+ # Basic options
30
+ bread_condition = Product.search_name("bread")
31
+ expect(Product.where(bread_condition)).to match_array [sour_bread, rye_bread]
32
+
33
+ ita_condition = Product.search_name("ita", prefix: true)
34
+ expect(Product.where(ita_condition)).to match_array [italian_pasta, sushi]
35
+
36
+ nonsour_condition = Product.search_name("!sour")
37
+ expect(Product.where(nonsour_condition)).to match_array [italian_pasta, rye_bread]
38
+
39
+ # Composing searches
40
+ non_sour_breads = Product.where(bread_condition.and(nonsour_condition))
41
+ expect(non_sour_breads).to match_array [rye_bread]
42
+
43
+ nonsour_or_ita = Product.where(nonsour_condition.or(ita_condition))
44
+ expect(nonsour_or_ita).to match_array [rye_bread, italian_pasta, sushi]
45
+ ```
46
+
47
+ ### Development
48
+
49
+ **Testing locally:**
50
+
51
+ 1. Start local Postgres: `bin/local-docker-testing`
52
+ 2. Run tests: `bundle exec rspec`
53
+
54
+ ### Credits
55
+
56
+ pg_search provided much of the inspiration and basis
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+
3
+ docker-compose -f docker-compose.test.yml up postgres
data/config.ru ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems"
4
+ require "bundler"
5
+
6
+ Bundler.require :default, :development
7
+
8
+ Combustion.initialize! :all
9
+ run Combustion::Application
@@ -0,0 +1,10 @@
1
+ version: "3"
2
+ services:
3
+ postgres:
4
+ image: postgres:12.1
5
+ ports:
6
+ - 5432:5432
7
+ environment:
8
+ POSTGRES_USERNAME: postgres
9
+ POSTGRES_PASSWORD: postgres
10
+ POSTGRES_DB: postgres_test
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module PgSearchable
6
+ module Model
7
+ extend ActiveSupport::Concern
8
+
9
+ module ClassMethods
10
+ def searchable_column(column_name)
11
+ name = "search_#{column_name.to_s}"
12
+ define_singleton_method(name) do |*args|
13
+ Searcher.new(self).exec(column_name, *args)
14
+ end
15
+ end
16
+ end
17
+
18
+ class Searcher
19
+ def initialize(model)
20
+ @model = model
21
+ end
22
+
23
+ def dictionary
24
+ Arel::Nodes.build_quoted(:simple)
25
+ end
26
+
27
+ def exec(column_name, query, raw:false, prefix:false)
28
+ column_ref = "#{@model.quoted_table_name}.#{column_name}"
29
+
30
+ vector = Arel::Nodes::NamedFunction.new(
31
+ "to_tsvector",
32
+ [dictionary, Arel.sql(column_ref)]
33
+ )
34
+
35
+ query = Builder.tsquery(query, prefix: prefix, raw: raw)
36
+
37
+ condition = Arel::Nodes::Grouping.new(
38
+ Arel::Nodes::InfixOperation.new("@@", vector, query)
39
+ )
40
+
41
+ condition
42
+ end
43
+ end
44
+
45
+ module Builder
46
+
47
+ DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’]/.freeze
48
+
49
+ def self.dictionary
50
+ Arel::Nodes.build_quoted(:simple)
51
+ end
52
+
53
+ def self.normalize(x)
54
+ x # TODO
55
+ end
56
+
57
+ def self.tsquery_for_terms(terms, prefix:)
58
+ terms = terms.map { |term| tsquery_term(term, prefix: prefix) }
59
+ terms.inject do |memo, term|
60
+ term_anded = Arel::Nodes::InfixOperation.new("||", Arel::Nodes.build_quoted(" & "), term)
61
+ Arel::Nodes::InfixOperation.new("||", memo, term_anded)
62
+ end
63
+ end
64
+
65
+ def self.tsquery_term(unsanitized_term, prefix:)
66
+ negated = false
67
+
68
+ if unsanitized_term.start_with?("!")
69
+ unsanitized_term[0] = ''
70
+ negated = true
71
+ end
72
+
73
+ sanitized_term = unsanitized_term.gsub(DISALLOWED_TSQUERY_CHARACTERS, " ")
74
+ tsquery_expression(sanitized_term, negated: negated, prefix: prefix)
75
+ end
76
+
77
+ # After this, the SQL expression evaluates to a string containing the term surrounded by single-quotes.
78
+ # If :prefix is true, then the term will have :* appended to the end.
79
+ # If :negated is true, then the term will have ! prepended to the front.
80
+ def self.tsquery_expression(term, negated:, prefix:)
81
+ terms = [
82
+ (Arel::Nodes.build_quoted('!') if negated),
83
+ Arel::Nodes.build_quoted("' "),
84
+ Arel::Nodes.build_quoted(term),
85
+ Arel::Nodes.build_quoted(" '"),
86
+ (Arel::Nodes.build_quoted(":*") if prefix)
87
+ ].compact
88
+
89
+ terms.inject do |memo, term|
90
+ Arel::Nodes::InfixOperation.new("||", memo, Arel::Nodes.build_quoted(term))
91
+ end
92
+ end
93
+
94
+ def self.tsquery(query, prefix:, raw:)
95
+ query_terms = query.split(" ").compact
96
+ tsq =
97
+ if query.blank?
98
+ Arel::Nodes.build_quoted("")
99
+ elsif raw
100
+ Arel::Nodes.build_quoted(query)
101
+ else
102
+ tsquery_for_terms(query_terms, prefix: prefix)
103
+ end
104
+
105
+ Arel::Nodes::NamedFunction.new("to_tsquery", [dictionary, tsq])
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSearchable
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg-searchable/model"
4
+ require "pg-searchable/version"
@@ -0,0 +1,11 @@
1
+ require_relative "lib/pg-searchable/version"
2
+
3
+ Gem::Specification.new do |gem|
4
+ gem.authors = ["wyozi"]
5
+ gem.name = "pg-searchable"
6
+ gem.summary = "Simple full-text searching for Rails"
7
+
8
+ gem.files = `git ls-files | grep -Ev '^(spec)'`.split("\n")
9
+ gem.version = PgSearchable::VERSION
10
+ gem.required_ruby_version = ">= 2.5.0"
11
+ end
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pg-searchable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - wyozi
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-03-21 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - ".editorconfig"
20
+ - ".github/workflows/test.yml"
21
+ - ".gitignore"
22
+ - ".rspec"
23
+ - Gemfile
24
+ - Gemfile.lock
25
+ - README.md
26
+ - bin/local-docker-testing
27
+ - config.ru
28
+ - docker-compose.test.yml
29
+ - lib/pg-searchable.rb
30
+ - lib/pg-searchable/model.rb
31
+ - lib/pg-searchable/version.rb
32
+ - pg-searchable.gemspec
33
+ homepage:
34
+ licenses: []
35
+ metadata: {}
36
+ post_install_message:
37
+ rdoc_options: []
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: 2.5.0
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubygems_version: 3.1.2
52
+ signing_key:
53
+ specification_version: 4
54
+ summary: Simple full-text searching for Rails
55
+ test_files: []