searchable-by 0.5.4 → 0.5.9
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/.github/workflows/ruby.yml +21 -0
- data/.rubocop.yml +7 -4
- data/Gemfile.lock +68 -47
- data/LICENSE +1 -1
- data/README.md +5 -2
- data/lib/searchable_by.rb +8 -145
- data/lib/searchable_by/column.rb +56 -0
- data/lib/searchable_by/concern.rb +43 -0
- data/lib/searchable_by/config.rb +31 -0
- data/lib/searchable_by/util.rb +46 -0
- data/searchable-by.gemspec +5 -5
- data/spec/searchable_by/util_spec.rb +35 -0
- data/spec/searchable_by_spec.rb +63 -54
- data/spec/spec_helper.rb +17 -9
- metadata +13 -7
- data/.travis.yml +0 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8b6f1b26859c89dfec031b51501c3f1dd6e8050d27bf7ae00bdb0ef9b8495422
|
4
|
+
data.tar.gz: a24dc8497c85648498fccffd56aaa4f995d7c31b48ab21e970356115a6a1ec50
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 33f4d29187d4bf9545af72632b1c17742be5ceeb3b699d36dc6e0f251151dabb06877269378d17f72dcb152f4413265866b9e076c537f5f0e1cf3f12689d3492
|
7
|
+
data.tar.gz: 6cba46e2c5ee6bf164490d5f7e2b7c715ae19dcc51d6eafbc3e93283e4d02e887e926eadc821753cf4b25522b5adb4322ac37a89d678beb76c33b7fd79fb193a
|
@@ -0,0 +1,21 @@
|
|
1
|
+
name: Ruby
|
2
|
+
|
3
|
+
on:
|
4
|
+
push:
|
5
|
+
branches: [main]
|
6
|
+
pull_request:
|
7
|
+
branches: [main]
|
8
|
+
|
9
|
+
jobs:
|
10
|
+
test:
|
11
|
+
runs-on: ubuntu-latest
|
12
|
+
strategy:
|
13
|
+
matrix:
|
14
|
+
ruby-version: ["2.6", "2.7", "3.0"]
|
15
|
+
steps:
|
16
|
+
- uses: actions/checkout@v2
|
17
|
+
- uses: ruby/setup-ruby@v1
|
18
|
+
with:
|
19
|
+
ruby-version: ${{ matrix.ruby-version }}
|
20
|
+
bundler-cache: true
|
21
|
+
- run: bundle exec rake
|
data/.rubocop.yml
CHANGED
@@ -1,9 +1,12 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
1
|
+
inherit_gem:
|
2
|
+
rubocop-bsm:
|
3
|
+
- default.yml
|
4
|
+
inherit_mode:
|
5
|
+
merge:
|
6
|
+
- Exclude
|
4
7
|
|
5
8
|
AllCops:
|
6
|
-
TargetRubyVersion: "2.
|
9
|
+
TargetRubyVersion: "2.6"
|
7
10
|
|
8
11
|
Naming/FileName:
|
9
12
|
Exclude:
|
data/Gemfile.lock
CHANGED
@@ -1,65 +1,86 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
searchable-by (0.5.
|
4
|
+
searchable-by (0.5.9)
|
5
5
|
activerecord
|
6
6
|
activesupport
|
7
7
|
|
8
8
|
GEM
|
9
9
|
remote: http://rubygems.org/
|
10
10
|
specs:
|
11
|
-
activemodel (6.
|
12
|
-
activesupport (= 6.
|
13
|
-
activerecord (6.
|
14
|
-
activemodel (= 6.
|
15
|
-
activesupport (= 6.
|
16
|
-
activesupport (6.
|
11
|
+
activemodel (6.1.3)
|
12
|
+
activesupport (= 6.1.3)
|
13
|
+
activerecord (6.1.3)
|
14
|
+
activemodel (= 6.1.3)
|
15
|
+
activesupport (= 6.1.3)
|
16
|
+
activesupport (6.1.3)
|
17
17
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
18
|
-
i18n (>=
|
19
|
-
minitest (
|
20
|
-
tzinfo (~>
|
21
|
-
zeitwerk (~> 2.
|
22
|
-
ast (2.4.
|
23
|
-
concurrent-ruby (1.1.
|
24
|
-
diff-lcs (1.
|
25
|
-
i18n (1.
|
18
|
+
i18n (>= 1.6, < 2)
|
19
|
+
minitest (>= 5.1)
|
20
|
+
tzinfo (~> 2.0)
|
21
|
+
zeitwerk (~> 2.3)
|
22
|
+
ast (2.4.2)
|
23
|
+
concurrent-ruby (1.1.8)
|
24
|
+
diff-lcs (1.4.4)
|
25
|
+
i18n (1.8.9)
|
26
26
|
concurrent-ruby (~> 1.0)
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
27
|
+
minitest (5.14.4)
|
28
|
+
parallel (1.20.1)
|
29
|
+
parser (3.0.0.0)
|
30
|
+
ast (~> 2.4.1)
|
31
|
+
rack (2.2.3)
|
32
32
|
rainbow (3.0.0)
|
33
|
-
rake (13.0.
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
rspec-
|
38
|
-
|
39
|
-
rspec-
|
40
|
-
rspec-
|
33
|
+
rake (13.0.3)
|
34
|
+
regexp_parser (2.1.1)
|
35
|
+
rexml (3.2.4)
|
36
|
+
rspec (3.10.0)
|
37
|
+
rspec-core (~> 3.10.0)
|
38
|
+
rspec-expectations (~> 3.10.0)
|
39
|
+
rspec-mocks (~> 3.10.0)
|
40
|
+
rspec-core (3.10.1)
|
41
|
+
rspec-support (~> 3.10.0)
|
42
|
+
rspec-expectations (3.10.1)
|
41
43
|
diff-lcs (>= 1.2.0, < 2.0)
|
42
|
-
rspec-support (~> 3.
|
43
|
-
rspec-mocks (3.
|
44
|
+
rspec-support (~> 3.10.0)
|
45
|
+
rspec-mocks (3.10.2)
|
44
46
|
diff-lcs (>= 1.2.0, < 2.0)
|
45
|
-
rspec-support (~> 3.
|
46
|
-
rspec-support (3.
|
47
|
-
rubocop (
|
48
|
-
jaro_winkler (~> 1.5.1)
|
47
|
+
rspec-support (~> 3.10.0)
|
48
|
+
rspec-support (3.10.2)
|
49
|
+
rubocop (1.11.0)
|
49
50
|
parallel (~> 1.10)
|
50
|
-
parser (>=
|
51
|
+
parser (>= 3.0.0.0)
|
51
52
|
rainbow (>= 2.2.2, < 4.0)
|
53
|
+
regexp_parser (>= 1.8, < 3.0)
|
54
|
+
rexml
|
55
|
+
rubocop-ast (>= 1.2.0, < 2.0)
|
52
56
|
ruby-progressbar (~> 1.7)
|
53
|
-
unicode-display_width (>= 1.4.0, <
|
54
|
-
rubocop-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
57
|
+
unicode-display_width (>= 1.4.0, < 3.0)
|
58
|
+
rubocop-ast (1.4.1)
|
59
|
+
parser (>= 2.7.1.5)
|
60
|
+
rubocop-bsm (0.5.4)
|
61
|
+
rubocop (~> 1.0)
|
62
|
+
rubocop-performance
|
63
|
+
rubocop-rails
|
64
|
+
rubocop-rake
|
65
|
+
rubocop-rspec
|
66
|
+
rubocop-performance (1.10.1)
|
67
|
+
rubocop (>= 0.90.0, < 2.0)
|
68
|
+
rubocop-ast (>= 0.4.0)
|
69
|
+
rubocop-rails (2.9.1)
|
70
|
+
activesupport (>= 4.2.0)
|
71
|
+
rack (>= 1.1)
|
72
|
+
rubocop (>= 0.90.0, < 2.0)
|
73
|
+
rubocop-rake (0.5.1)
|
74
|
+
rubocop
|
75
|
+
rubocop-rspec (2.2.0)
|
76
|
+
rubocop (~> 1.0)
|
77
|
+
rubocop-ast (>= 1.1.0)
|
78
|
+
ruby-progressbar (1.11.0)
|
79
|
+
sqlite3 (1.4.2)
|
80
|
+
tzinfo (2.0.4)
|
81
|
+
concurrent-ruby (~> 1.0)
|
82
|
+
unicode-display_width (2.0.0)
|
83
|
+
zeitwerk (2.4.2)
|
63
84
|
|
64
85
|
PLATFORMS
|
65
86
|
ruby
|
@@ -69,9 +90,9 @@ DEPENDENCIES
|
|
69
90
|
rake
|
70
91
|
rspec
|
71
92
|
rubocop
|
72
|
-
rubocop-
|
93
|
+
rubocop-bsm
|
73
94
|
searchable-by!
|
74
95
|
sqlite3
|
75
96
|
|
76
97
|
BUNDLED WITH
|
77
|
-
2.
|
98
|
+
2.2.5
|
data/LICENSE
CHANGED
data/README.md
CHANGED
@@ -14,8 +14,11 @@ class Post < ActiveRecord::Base
|
|
14
14
|
|
15
15
|
# Limit the number of terms per query to 3.
|
16
16
|
searchable_by max_terms: 3 do
|
17
|
-
# Allow to search strings.
|
18
|
-
column :title
|
17
|
+
# Allow to search strings with custom match type.
|
18
|
+
column :title,
|
19
|
+
match: :prefix, # Use btree index-friendly prefix match, e.g. `ILIKE 'term%'` instead of default `ILIKE '%term%'`.
|
20
|
+
match_phrase: :exact, # For phrases use exact match type, e.g. searching for `"My Post"` will query `WHERE LOWER(title) = 'my post'`.
|
21
|
+
min_length: 3 # Return no-match if search term is too short (useful for trigram indexes).
|
19
22
|
|
20
23
|
# ... and integers.
|
21
24
|
column :id, type: :integer
|
data/lib/searchable_by.rb
CHANGED
@@ -1,149 +1,12 @@
|
|
1
1
|
require 'active_record'
|
2
2
|
|
3
|
-
module
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
3
|
+
module SearchableBy
|
4
|
+
autoload :Column, 'searchable_by/column'
|
5
|
+
autoload :Concern, 'searchable_by/concern'
|
6
|
+
autoload :Config, 'searchable_by/config'
|
7
|
+
autoload :Util, 'searchable_by/util'
|
8
8
|
|
9
|
-
|
10
|
-
@attr = attr
|
11
|
-
@type = type.to_sym
|
12
|
-
end
|
13
|
-
end
|
14
|
-
|
15
|
-
class Config
|
16
|
-
attr_reader :columns, :scoping
|
17
|
-
attr_accessor :max_terms
|
18
|
-
|
19
|
-
def initialize
|
20
|
-
@columns = []
|
21
|
-
@max_terms = 5
|
22
|
-
scope { all }
|
23
|
-
end
|
24
|
-
|
25
|
-
def initialize_copy(other)
|
26
|
-
@columns = other.columns.dup
|
27
|
-
super
|
28
|
-
end
|
29
|
-
|
30
|
-
def column(*attrs, &block)
|
31
|
-
opts = attrs.extract_options!
|
32
|
-
attrs.each do |attr|
|
33
|
-
columns.push Column.new(attr, opts)
|
34
|
-
end
|
35
|
-
columns.push Column.new(block, opts) if block
|
36
|
-
columns
|
37
|
-
end
|
38
|
-
|
39
|
-
def scope(&block)
|
40
|
-
@scoping = block
|
41
|
-
end
|
42
|
-
end
|
43
|
-
|
44
|
-
Value = Struct.new(:term, :negate)
|
45
|
-
|
46
|
-
def self.norm_values(query)
|
47
|
-
values = []
|
48
|
-
query = query.to_s.dup
|
49
|
-
|
50
|
-
# capture any terms inside double quotes
|
51
|
-
# exclude from seach if preceded by '-'
|
52
|
-
query.gsub!(/([\-\+]?)"+([^"]*)"+/) do |_|
|
53
|
-
term = Regexp.last_match(2)
|
54
|
-
negate = Regexp.last_match(1) == '-'
|
55
|
-
|
56
|
-
values.push Value.new(term, negate) unless term.blank?
|
57
|
-
''
|
58
|
-
end
|
59
|
-
|
60
|
-
# for the remaining terms remove sign if precedes
|
61
|
-
# exclude term from search if sign preceding is '-'
|
62
|
-
query.split(' ').each do |term|
|
63
|
-
negate = term[0] == '-'
|
64
|
-
term.slice!(0) if negate || term[0] == '+'
|
65
|
-
|
66
|
-
values.push Value.new(term, negate) unless term.blank?
|
67
|
-
end
|
68
|
-
|
69
|
-
values.uniq!
|
70
|
-
values
|
71
|
-
end
|
72
|
-
|
73
|
-
def self.build_clauses(columns, values)
|
74
|
-
clauses = values.map do |value|
|
75
|
-
grouping = columns.map do |column|
|
76
|
-
build_condition(column, value)
|
77
|
-
end
|
78
|
-
grouping.compact!
|
79
|
-
next if grouping.empty?
|
80
|
-
|
81
|
-
clause = grouping.inject(&:or)
|
82
|
-
clause = clause.not if value.negate
|
83
|
-
clause
|
84
|
-
end
|
85
|
-
clauses.compact!
|
86
|
-
clauses
|
87
|
-
end
|
88
|
-
|
89
|
-
def self.build_condition(column, value)
|
90
|
-
case column.type
|
91
|
-
when :int, :integer
|
92
|
-
begin
|
93
|
-
column.node.not_eq(nil).and(column.node.eq(Integer(value.term)))
|
94
|
-
rescue ArgumentError
|
95
|
-
nil
|
96
|
-
end
|
97
|
-
else
|
98
|
-
term = value.term.dup
|
99
|
-
term.gsub!('%', '\%')
|
100
|
-
term.gsub!('_', '\_')
|
101
|
-
column.node.not_eq(nil).and(column.node.matches("%#{term}%"))
|
102
|
-
end
|
103
|
-
end
|
104
|
-
|
105
|
-
module ClassMethods
|
106
|
-
def self.extended(base) # :nodoc:
|
107
|
-
base.class_attribute :_searchable_by_config, instance_accessor: false, instance_predicate: false
|
108
|
-
base._searchable_by_config = Config.new
|
109
|
-
super
|
110
|
-
end
|
111
|
-
|
112
|
-
def inherited(base) # :nodoc:
|
113
|
-
base._searchable_by_config = _searchable_by_config.dup
|
114
|
-
super
|
115
|
-
end
|
116
|
-
|
117
|
-
def searchable_by(max_terms: 5, &block)
|
118
|
-
_searchable_by_config.instance_eval(&block)
|
119
|
-
_searchable_by_config.max_terms = max_terms if max_terms
|
120
|
-
end
|
121
|
-
|
122
|
-
# @param [String] query the search query
|
123
|
-
# @return [ActiveRecord::Relation] the scoped relation
|
124
|
-
def search_by(query)
|
125
|
-
columns = _searchable_by_config.columns
|
126
|
-
return all if columns.empty?
|
127
|
-
|
128
|
-
values = SearchableBy.norm_values(query).first(_searchable_by_config.max_terms)
|
129
|
-
return all if values.empty?
|
130
|
-
|
131
|
-
columns.each do |col|
|
132
|
-
col.node ||= col.attr.is_a?(Proc) ? col.attr.call : arel_table[col.attr]
|
133
|
-
end
|
134
|
-
clauses = SearchableBy.build_clauses(columns, values)
|
135
|
-
return all if clauses.empty?
|
136
|
-
|
137
|
-
scope = instance_exec(&_searchable_by_config.scoping)
|
138
|
-
clauses.each do |clause|
|
139
|
-
scope = scope.where(clause)
|
140
|
-
end
|
141
|
-
scope
|
142
|
-
end
|
143
|
-
end
|
144
|
-
end
|
145
|
-
|
146
|
-
class Base
|
147
|
-
extend SearchableBy::ClassMethods
|
148
|
-
end
|
9
|
+
Value = Struct.new(:term, :negate, :phrase)
|
149
10
|
end
|
11
|
+
|
12
|
+
ActiveRecord::Base.extend SearchableBy::Concern if defined?(::ActiveRecord::Base)
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module SearchableBy
|
2
|
+
class Column
|
3
|
+
attr_reader :attr, :type, :match, :match_phrase, :min_length, :wildcard
|
4
|
+
attr_accessor :node
|
5
|
+
|
6
|
+
def initialize(attr, type: :string, match: :all, match_phrase: nil, min_length: 0, wildcard: nil) # rubocop:disable Metrics/ParameterLists
|
7
|
+
@attr = attr
|
8
|
+
@type = type.to_sym
|
9
|
+
@match = match
|
10
|
+
@match_phrase = match_phrase || match
|
11
|
+
@min_length = min_length
|
12
|
+
@wildcard = wildcard
|
13
|
+
end
|
14
|
+
|
15
|
+
def build_condition(value)
|
16
|
+
return Arel::Nodes::False.new if value.term.length < min_length # no-match
|
17
|
+
|
18
|
+
scope = node.not_eq(nil)
|
19
|
+
|
20
|
+
case type
|
21
|
+
when :int, :integer
|
22
|
+
int_condition(scope, value)
|
23
|
+
else
|
24
|
+
str_condition(scope, value)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def int_condition(scope, value)
|
31
|
+
scope.and(node.eq(Integer(value.term)))
|
32
|
+
rescue ArgumentError
|
33
|
+
nil
|
34
|
+
end
|
35
|
+
|
36
|
+
def str_condition(scope, value)
|
37
|
+
term = value.term.dup
|
38
|
+
type = value.phrase ? match_phrase : match
|
39
|
+
|
40
|
+
case type
|
41
|
+
when :exact
|
42
|
+
term.downcase!
|
43
|
+
scope.and(node.lower.eq(term))
|
44
|
+
when :prefix
|
45
|
+
term.gsub!('%', '\%')
|
46
|
+
term.gsub!('_', '\_')
|
47
|
+
scope.and(node.matches("#{term}%"))
|
48
|
+
else
|
49
|
+
term.gsub!('%', '\%')
|
50
|
+
term.gsub!('_', '\_')
|
51
|
+
term.gsub!(wildcard, '%') if wildcard
|
52
|
+
scope.and(node.matches("%#{term}%"))
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module SearchableBy
|
2
|
+
module Concern
|
3
|
+
def self.extended(base) # :nodoc:
|
4
|
+
base.class_attribute :_searchable_by_config, instance_accessor: false, instance_predicate: false
|
5
|
+
base._searchable_by_config = Config.new
|
6
|
+
super
|
7
|
+
end
|
8
|
+
|
9
|
+
def inherited(base) # :nodoc:
|
10
|
+
base._searchable_by_config = _searchable_by_config.dup
|
11
|
+
super
|
12
|
+
end
|
13
|
+
|
14
|
+
def searchable_by(max_terms: nil, **options, &block)
|
15
|
+
_searchable_by_config.instance_eval(&block)
|
16
|
+
_searchable_by_config.max_terms = max_terms if max_terms
|
17
|
+
_searchable_by_config.options.update(options) unless options.empty?
|
18
|
+
_searchable_by_config
|
19
|
+
end
|
20
|
+
|
21
|
+
# @param [String] query the search query
|
22
|
+
# @return [ActiveRecord::Relation] the scoped relation
|
23
|
+
def search_by(query)
|
24
|
+
columns = _searchable_by_config.columns
|
25
|
+
return all if columns.empty?
|
26
|
+
|
27
|
+
values = Util.norm_values(query).first(_searchable_by_config.max_terms)
|
28
|
+
return all if values.empty?
|
29
|
+
|
30
|
+
columns.each do |col|
|
31
|
+
col.node ||= col.attr.is_a?(Proc) ? col.attr.call : arel_table[col.attr]
|
32
|
+
end
|
33
|
+
clauses = Util.build_clauses(columns, values)
|
34
|
+
return all if clauses.empty?
|
35
|
+
|
36
|
+
scope = instance_exec(&_searchable_by_config.scoping)
|
37
|
+
clauses.each do |clause|
|
38
|
+
scope = scope.where(clause)
|
39
|
+
end
|
40
|
+
scope
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module SearchableBy
|
2
|
+
class Config
|
3
|
+
attr_reader :columns, :scoping, :options
|
4
|
+
attr_accessor :max_terms
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@columns = []
|
8
|
+
@max_terms = 5
|
9
|
+
@options = {}
|
10
|
+
scope { all }
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize_copy(other)
|
14
|
+
@columns = other.columns.dup
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
def column(*attrs, &block)
|
19
|
+
opts = attrs.extract_options!
|
20
|
+
attrs.each do |attr|
|
21
|
+
columns.push Column.new(attr, **@options, **opts)
|
22
|
+
end
|
23
|
+
columns.push Column.new(block, **@options, **opts) if block
|
24
|
+
columns
|
25
|
+
end
|
26
|
+
|
27
|
+
def scope(&block)
|
28
|
+
@scoping = block
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module SearchableBy
|
2
|
+
module Util
|
3
|
+
def self.norm_values(query)
|
4
|
+
values = []
|
5
|
+
query = query.to_s.dup
|
6
|
+
|
7
|
+
# capture any phrases inside double quotes
|
8
|
+
# exclude from search if preceded by '-'
|
9
|
+
query.gsub!(/([\-+]?)"+([^"]*)"+/) do |_|
|
10
|
+
term = Regexp.last_match(2)
|
11
|
+
negate = Regexp.last_match(1) == '-'
|
12
|
+
|
13
|
+
values.push Value.new(term, negate, true) unless term.blank?
|
14
|
+
''
|
15
|
+
end
|
16
|
+
|
17
|
+
# for the remaining terms remove sign if precedes
|
18
|
+
# exclude term from search if sign preceding is '-'
|
19
|
+
query.split.each do |term|
|
20
|
+
negate = term[0] == '-'
|
21
|
+
term.slice!(0) if negate || term[0] == '+'
|
22
|
+
|
23
|
+
values.push Value.new(term, negate, false) unless term.blank?
|
24
|
+
end
|
25
|
+
|
26
|
+
values.uniq!
|
27
|
+
values
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.build_clauses(columns, values)
|
31
|
+
clauses = values.map do |value|
|
32
|
+
grouping = columns.map do |column|
|
33
|
+
column.build_condition(value)
|
34
|
+
end
|
35
|
+
grouping.compact!
|
36
|
+
next if grouping.empty?
|
37
|
+
|
38
|
+
clause = grouping.inject(&:or)
|
39
|
+
clause = clause.not if value.negate
|
40
|
+
clause
|
41
|
+
end
|
42
|
+
clauses.compact!
|
43
|
+
clauses
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
data/searchable-by.gemspec
CHANGED
@@ -1,17 +1,17 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = 'searchable-by'
|
3
|
-
s.version = '0.5.
|
3
|
+
s.version = '0.5.9'
|
4
4
|
s.authors = ['Dimitrij Denissenko']
|
5
5
|
s.email = ['dimitrij@blacksquaremedia.com']
|
6
6
|
s.summary = 'Generate search scopes'
|
7
7
|
s.description = 'ActiveRecord plugin'
|
8
8
|
s.homepage = 'https://github.com/bsm/sortable-by'
|
9
|
-
s.license = '
|
9
|
+
s.license = 'Apache-2.0'
|
10
10
|
|
11
|
-
s.files = `git ls-files -z`.split("\x0").reject {|f| f.
|
11
|
+
s.files = `git ls-files -z`.split("\x0").reject {|f| f.start_with?('spec/') }
|
12
12
|
s.test_files = `git ls-files -z -- spec/*`.split("\x0")
|
13
13
|
s.require_paths = ['lib']
|
14
|
-
s.required_ruby_version = '>= 2.
|
14
|
+
s.required_ruby_version = '>= 2.6'
|
15
15
|
|
16
16
|
s.add_dependency 'activerecord'
|
17
17
|
s.add_dependency 'activesupport'
|
@@ -20,6 +20,6 @@ Gem::Specification.new do |s|
|
|
20
20
|
s.add_development_dependency 'rake'
|
21
21
|
s.add_development_dependency 'rspec'
|
22
22
|
s.add_development_dependency 'rubocop'
|
23
|
-
s.add_development_dependency 'rubocop-
|
23
|
+
s.add_development_dependency 'rubocop-bsm'
|
24
24
|
s.add_development_dependency 'sqlite3'
|
25
25
|
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe SearchableBy::Util do
|
4
|
+
context 'with norm_values' do
|
5
|
+
def norm(str)
|
6
|
+
described_class.norm_values(str).each_with_object({}) do |val, acc|
|
7
|
+
acc[val.term] = val.negate
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'tokenises strings' do
|
12
|
+
expect(norm(nil)).to eq({})
|
13
|
+
expect(norm('""')).to eq({})
|
14
|
+
expect(norm('-+""')).to eq({})
|
15
|
+
expect(norm('simple words')).to eq('simple' => false, 'words' => false)
|
16
|
+
expect(norm(" with \t spaces\n")).to eq('with' => false, 'spaces' => false)
|
17
|
+
expect(norm('with with duplicates with')).to eq('with' => false, 'duplicates' => false)
|
18
|
+
expect(norm('with "full term"')).to eq('full term' => false, 'with' => false)
|
19
|
+
expect(norm('"""odd double quotes around"""')).to eq('odd double quotes around' => false)
|
20
|
+
expect(norm('""even double quotes around""')).to eq('even double quotes around' => false)
|
21
|
+
expect(norm('with\'apostrophe')).to eq("with'apostrophe" => false)
|
22
|
+
expect(norm('with -minus')).to eq('minus' => true, 'with' => false)
|
23
|
+
expect(norm('with +plus')).to eq('plus' => false, 'with' => false)
|
24
|
+
expect(norm('with-minus')).to eq('with-minus' => false)
|
25
|
+
expect(norm('with+plus')).to eq('with+plus' => false)
|
26
|
+
expect(norm('with -"minus before"')).to eq('minus before' => true, 'with' => false)
|
27
|
+
expect(norm('with "-minus within"')).to eq('-minus within' => false, 'with' => false)
|
28
|
+
expect(norm('with +"plus before"')).to eq('plus before' => false, 'with' => false)
|
29
|
+
expect(norm('with "+plus within"')).to eq('+plus within' => false, 'with' => false)
|
30
|
+
expect(norm('+plus "in other term"')).to eq('in other term' => false, 'plus' => false)
|
31
|
+
expect(norm('with_blank \'\'')).to eq('with_blank' => false, '\'\'' => false)
|
32
|
+
expect(norm('with_blank_doubles ""')).to eq('with_blank_doubles' => false)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/spec/searchable_by_spec.rb
CHANGED
@@ -1,80 +1,89 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
describe
|
4
|
-
|
5
|
-
def norm(str)
|
6
|
-
described_class.norm_values(str).each_with_object({}) do |val, acc|
|
7
|
-
acc[val.term] = val.negate
|
8
|
-
end
|
9
|
-
end
|
10
|
-
|
11
|
-
it 'should tokenise strings' do
|
12
|
-
expect(norm(nil)).to eq({})
|
13
|
-
expect(norm('""')).to eq({})
|
14
|
-
expect(norm('-+""')).to eq({})
|
15
|
-
expect(norm('simple words')).to eq('simple' => false, 'words' => false)
|
16
|
-
expect(norm(" with \t spaces\n")).to eq('with' => false, 'spaces' => false)
|
17
|
-
expect(norm('with with duplicates with')).to eq('with' => false, 'duplicates' => false)
|
18
|
-
expect(norm('with "full term"')).to eq('full term' => false, 'with' => false)
|
19
|
-
expect(norm('"""odd double quotes around"""')).to eq('odd double quotes around' => false)
|
20
|
-
expect(norm('""even double quotes around""')).to eq('even double quotes around'=> false)
|
21
|
-
expect(norm('with\'apostrophe')).to eq("with'apostrophe" => false)
|
22
|
-
expect(norm('with -minus')).to eq('minus' => true, 'with' => false)
|
23
|
-
expect(norm('with +plus')).to eq('plus' => false, 'with' => false)
|
24
|
-
expect(norm('with-minus')).to eq('with-minus' => false)
|
25
|
-
expect(norm('with+plus')).to eq('with+plus' => false)
|
26
|
-
expect(norm('with -"minus before"')).to eq('minus before' => true, 'with' => false)
|
27
|
-
expect(norm('with "-minus within"')).to eq('-minus within' => false, 'with' => false)
|
28
|
-
expect(norm('with +"plus before"')).to eq('plus before' => false, 'with' => false)
|
29
|
-
expect(norm('with "+plus within"')).to eq('+plus within' => false, 'with' => false)
|
30
|
-
expect(norm('+plus "in other term"')).to eq('in other term' => false, 'plus' => false)
|
31
|
-
expect(norm('with_blank \'\'')).to eq('with_blank' => false, '\'\'' => false)
|
32
|
-
expect(norm('with_blank_doubles ""')).to eq('with_blank_doubles' => false)
|
33
|
-
end
|
34
|
-
end
|
35
|
-
|
36
|
-
it 'should ignore bad inputs' do
|
3
|
+
describe SearchableBy do
|
4
|
+
it 'ignores bad inputs' do
|
37
5
|
expect(Post.search_by(nil).count).to eq(5)
|
38
6
|
expect(Post.search_by('').count).to eq(5)
|
39
7
|
end
|
40
8
|
|
41
|
-
it '
|
9
|
+
it 'configures correctly' do
|
42
10
|
expect(AbstractModel._searchable_by_config.columns.size).to eq(1)
|
43
11
|
expect(Post._searchable_by_config.columns.size).to eq(5)
|
44
12
|
end
|
45
13
|
|
46
|
-
it '
|
14
|
+
it 'generates SQL' do
|
47
15
|
sql = Post.search_by('123').to_sql
|
48
|
-
expect(sql).to include(%("posts"."id" = 123))
|
49
|
-
expect(sql).to include(%("posts"."title" LIKE '
|
16
|
+
expect(sql).to include(%("posts"."id" IS NOT NULL AND "posts"."id" = 123))
|
17
|
+
expect(sql).to include(%("posts"."title" IS NOT NULL AND "posts"."title" LIKE '123%'))
|
18
|
+
expect(sql).to include(%("posts"."body" IS NOT NULL AND "posts"."body" LIKE '%123%'))
|
19
|
+
expect(sql).to include(%("users"."name" IS NOT NULL AND LOWER("users"."name") = '123'))
|
50
20
|
|
51
21
|
sql = Post.search_by('foo%bar').to_sql
|
52
22
|
expect(sql).not_to include(%("posts"."id"))
|
53
|
-
expect(sql).to include(%("posts"."title" LIKE '
|
23
|
+
expect(sql).to include(%("posts"."title" LIKE 'foo\\%bar%'))
|
24
|
+
expect(sql).to include(%("posts"."body" LIKE '%foo\\%bar%'))
|
25
|
+
|
26
|
+
sql = User.search_by('uni*dom').to_sql
|
27
|
+
expect(sql).to include(%("users"."country" LIKE '%uni%dom%'))
|
28
|
+
|
29
|
+
sql = User.search_by('"uni * dom"').to_sql
|
30
|
+
expect(sql).to include(%("users"."country" LIKE '%uni % dom%'))
|
54
31
|
end
|
55
32
|
|
56
|
-
it '
|
57
|
-
expect(Post.search_by('ALICE').pluck(:title)).to match_array(%w[
|
58
|
-
expect(Post.search_by('bOb').pluck(:title)).to match_array(%w[
|
33
|
+
it 'searches' do
|
34
|
+
expect(Post.search_by('ALICE').pluck(:title)).to match_array(%w[ax1 ax2 ab1])
|
35
|
+
expect(Post.search_by('bOb').pluck(:title)).to match_array(%w[bx1 bx2 ab1])
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'searches across multiple words' do
|
39
|
+
expect(Post.search_by('ALICE your').pluck(:title)).to match_array(%w[ax2])
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'supports search markers' do
|
43
|
+
expect(Post.search_by('aLiCe -your').pluck(:title)).to match_array(%w[ax1 ab1])
|
44
|
+
expect(Post.search_by('+alice "your recipe"').pluck(:title)).to match_array(%w[ax2])
|
45
|
+
expect(Post.search_by('bob -"her recipe"').pluck(:title)).to match_array(%w[bx2 ab1])
|
46
|
+
expect(Post.search_by('bob +"her recipe"').pluck(:title)).to match_array(%w[bx1])
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'respects match options' do
|
50
|
+
# name uses match: :exact
|
51
|
+
expect(Post.search_by('alice').pluck(:title)).to match_array(%w[ax1 ax2 ab1])
|
52
|
+
expect(Post.search_by('ali').pluck(:title)).to be_empty
|
53
|
+
expect(Post.search_by('lice').pluck(:title)).to be_empty
|
54
|
+
expect(Post.search_by('li').pluck(:title)).to be_empty
|
55
|
+
|
56
|
+
# title uses match: :prefix
|
57
|
+
expect(Post.search_by('ax').pluck(:title)).to match_array(%w[ax1 ax2])
|
58
|
+
expect(Post.search_by('bx').pluck(:title)).to match_array(%w[bx1 bx2])
|
59
|
+
expect(Post.search_by('ab').pluck(:title)).to match_array(%w[ab1])
|
60
|
+
expect(Post.search_by('ba').pluck(:title)).to be_empty
|
61
|
+
|
62
|
+
# title uses match_phrase: :exact
|
63
|
+
expect(Post.search_by('"ab"').pluck(:title)).to be_empty
|
64
|
+
expect(Post.search_by('"ab1"').pluck(:title)).to match_array(%w[ab1])
|
65
|
+
|
66
|
+
# body uses match: :all (default)
|
67
|
+
expect(Post.search_by('recip').pluck(:title)).to match_array(%w[ax1 ax2 bx1 bx2 ab1])
|
59
68
|
end
|
60
69
|
|
61
|
-
it '
|
62
|
-
expect(
|
70
|
+
it 'supports min term length' do
|
71
|
+
expect(User.search_by('+ir')).to be_empty
|
72
|
+
expect(User.search_by('irs')).to match_array([USERS[:a]])
|
63
73
|
end
|
64
74
|
|
65
|
-
it '
|
66
|
-
expect(Post.search_by('
|
67
|
-
expect(Post.search_by('
|
68
|
-
expect(Post.search_by('bob -"her recipe"').pluck(:title)).to match_array(%w[b2 ab])
|
69
|
-
expect(Post.search_by('bob +"her recipe"').pluck(:title)).to match_array(%w[b1])
|
75
|
+
it 'searches within scopes' do
|
76
|
+
expect(Post.where(title: 'ax1').search_by('ALICE').pluck(:title)).to match_array(%w[ax1])
|
77
|
+
expect(Post.where(title: 'ax1').search_by('bOb').pluck(:title)).to be_empty
|
70
78
|
end
|
71
79
|
|
72
|
-
it '
|
73
|
-
expect(Post.
|
74
|
-
expect(Post.where(title: 'a1').search_by('bOb').pluck(:title)).to match_array(%w[])
|
80
|
+
it 'searches integers' do
|
81
|
+
expect(Post.search_by(POSTS[:ab1].id.to_s).count).to eq(1)
|
75
82
|
end
|
76
83
|
|
77
|
-
it '
|
78
|
-
expect(
|
84
|
+
it 'supports wildcard searching' do
|
85
|
+
expect(User.search_by('uni*dom')).to match_array([USERS[:a]])
|
86
|
+
expect(User.search_by('uni*o')).to match_array([USERS[:a], USERS[:b]])
|
87
|
+
expect(User.search_by('uni*of*dom')).to be_empty
|
79
88
|
end
|
80
89
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -8,6 +8,8 @@ ActiveRecord::Base.establish_connection :test
|
|
8
8
|
ActiveRecord::Base.connection.instance_eval do
|
9
9
|
create_table :users do |t|
|
10
10
|
t.string :name
|
11
|
+
t.string :bio
|
12
|
+
t.string :country
|
11
13
|
end
|
12
14
|
create_table :posts do |t|
|
13
15
|
t.integer :author_id, null: false
|
@@ -27,6 +29,11 @@ end
|
|
27
29
|
|
28
30
|
class User < AbstractModel
|
29
31
|
has_many :posts, foreign_key: :author_id
|
32
|
+
|
33
|
+
searchable_by do
|
34
|
+
column :bio, min_length: 3
|
35
|
+
column :country, wildcard: '*'
|
36
|
+
end
|
30
37
|
end
|
31
38
|
|
32
39
|
class Post < AbstractModel
|
@@ -34,8 +41,9 @@ class Post < AbstractModel
|
|
34
41
|
belongs_to :reviewer, class_name: 'User'
|
35
42
|
|
36
43
|
searchable_by do
|
37
|
-
column :title, :
|
38
|
-
column
|
44
|
+
column :title, match: :prefix, match_phrase: :exact
|
45
|
+
column :body
|
46
|
+
column proc { User.arel_table[:name] }, match: :exact
|
39
47
|
column { User.arel_table.alias('reviewers_posts')[:name] }
|
40
48
|
|
41
49
|
scope do
|
@@ -45,14 +53,14 @@ class Post < AbstractModel
|
|
45
53
|
end
|
46
54
|
|
47
55
|
USERS = {
|
48
|
-
a: User.create!(name: 'Alice'),
|
49
|
-
b: User.create!(name: 'Bob'),
|
56
|
+
a: User.create!(name: 'Alice', bio: 'First user', country: 'United Kingdom'),
|
57
|
+
b: User.create!(name: 'Bob', bio: 'Second user', country: 'United States of America'),
|
50
58
|
}.freeze
|
51
59
|
|
52
60
|
POSTS = {
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
61
|
+
ax1: USERS[:a].posts.create!(title: 'ax1', body: 'my recipe '),
|
62
|
+
ax2: USERS[:a].posts.create!(title: 'ax2', body: 'your recipe'),
|
63
|
+
bx1: USERS[:b].posts.create!(title: 'bx1', body: 'her recipe'),
|
64
|
+
bx2: USERS[:b].posts.create!(title: 'bx2', body: 'our recipe'),
|
65
|
+
ab1: USERS[:a].posts.create!(title: 'ab1', reviewer: USERS[:b], body: 'their recipe'),
|
58
66
|
}.freeze
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: searchable-by
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.5.
|
4
|
+
version: 0.5.9
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Dimitrij Denissenko
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-04-27 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -95,7 +95,7 @@ dependencies:
|
|
95
95
|
- !ruby/object:Gem::Version
|
96
96
|
version: '0'
|
97
97
|
- !ruby/object:Gem::Dependency
|
98
|
-
name: rubocop-
|
98
|
+
name: rubocop-bsm
|
99
99
|
requirement: !ruby/object:Gem::Requirement
|
100
100
|
requirements:
|
101
101
|
- - ">="
|
@@ -129,9 +129,9 @@ executables: []
|
|
129
129
|
extensions: []
|
130
130
|
extra_rdoc_files: []
|
131
131
|
files:
|
132
|
+
- ".github/workflows/ruby.yml"
|
132
133
|
- ".gitignore"
|
133
134
|
- ".rubocop.yml"
|
134
|
-
- ".travis.yml"
|
135
135
|
- Gemfile
|
136
136
|
- Gemfile.lock
|
137
137
|
- LICENSE
|
@@ -139,12 +139,17 @@ files:
|
|
139
139
|
- Rakefile
|
140
140
|
- lib/searchable-by.rb
|
141
141
|
- lib/searchable_by.rb
|
142
|
+
- lib/searchable_by/column.rb
|
143
|
+
- lib/searchable_by/concern.rb
|
144
|
+
- lib/searchable_by/config.rb
|
145
|
+
- lib/searchable_by/util.rb
|
142
146
|
- searchable-by.gemspec
|
147
|
+
- spec/searchable_by/util_spec.rb
|
143
148
|
- spec/searchable_by_spec.rb
|
144
149
|
- spec/spec_helper.rb
|
145
150
|
homepage: https://github.com/bsm/sortable-by
|
146
151
|
licenses:
|
147
|
-
-
|
152
|
+
- Apache-2.0
|
148
153
|
metadata: {}
|
149
154
|
post_install_message:
|
150
155
|
rdoc_options: []
|
@@ -154,17 +159,18 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
154
159
|
requirements:
|
155
160
|
- - ">="
|
156
161
|
- !ruby/object:Gem::Version
|
157
|
-
version: '2.
|
162
|
+
version: '2.6'
|
158
163
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
159
164
|
requirements:
|
160
165
|
- - ">="
|
161
166
|
- !ruby/object:Gem::Version
|
162
167
|
version: '0'
|
163
168
|
requirements: []
|
164
|
-
rubygems_version: 3.
|
169
|
+
rubygems_version: 3.1.4
|
165
170
|
signing_key:
|
166
171
|
specification_version: 4
|
167
172
|
summary: Generate search scopes
|
168
173
|
test_files:
|
174
|
+
- spec/searchable_by/util_spec.rb
|
169
175
|
- spec/searchable_by_spec.rb
|
170
176
|
- spec/spec_helper.rb
|