searchable-by 0.5.1 → 0.5.7
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/.rubocop.yml +7 -3
- data/.travis.yml +0 -1
- data/Gemfile.lock +58 -46
- data/LICENSE +1 -1
- data/README.md +4 -2
- data/lib/searchable_by.rb +8 -128
- data/lib/searchable_by/column.rb +51 -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 -3
- data/spec/searchable_by/util_spec.rb +35 -0
- data/spec/searchable_by_spec.rb +49 -25
- data/spec/spec_helper.rb +20 -15
- metadata +38 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2137729d80e65107cadf27ede26a60887b6888c41f6940caea9828e3416f137f
|
4
|
+
data.tar.gz: 6edd61ef3f32e0fa6e882b07f5d24004a5c7a4cd5ec2f9c99ba2e6e12332f5e2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 704a8beb8c791dc20e6d156f73a3c3b21f6aed0df0d94d0cc7dda78440d9051f4a9f9a3d076fa3afa5af54a4312e9251335d2ab2911c228506c23d877ef67789
|
7
|
+
data.tar.gz: '0906f2424988065b3d22a177ab260d42029ad3e531a8b5e051fc7170d87e87784fa18225eba4f6631416af7a0c9a3df5c1b3e464babdb8d56ace5f226b18ee39'
|
data/.rubocop.yml
CHANGED
@@ -1,10 +1,14 @@
|
|
1
|
-
require:
|
1
|
+
require:
|
2
|
+
- rubocop-performance
|
3
|
+
- rubocop-rake
|
4
|
+
- rubocop-rspec
|
5
|
+
|
2
6
|
inherit_from:
|
3
7
|
- https://gitlab.com/bsm/misc/raw/master/rubocop/default.yml
|
4
8
|
|
5
9
|
AllCops:
|
6
|
-
TargetRubyVersion: "2.
|
10
|
+
TargetRubyVersion: "2.5"
|
7
11
|
|
8
12
|
Naming/FileName:
|
9
13
|
Exclude:
|
10
|
-
- lib/searchable-by.rb
|
14
|
+
- lib/searchable-by.rb
|
data/.travis.yml
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,65 +1,75 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
searchable-by (0.5.
|
4
|
+
searchable-by (0.5.7)
|
5
5
|
activerecord
|
6
6
|
activesupport
|
7
7
|
|
8
8
|
GEM
|
9
9
|
remote: http://rubygems.org/
|
10
10
|
specs:
|
11
|
-
activemodel (
|
12
|
-
activesupport (=
|
13
|
-
activerecord (
|
14
|
-
activemodel (=
|
15
|
-
activesupport (=
|
16
|
-
|
17
|
-
activesupport (5.2.3)
|
11
|
+
activemodel (6.1.1)
|
12
|
+
activesupport (= 6.1.1)
|
13
|
+
activerecord (6.1.1)
|
14
|
+
activemodel (= 6.1.1)
|
15
|
+
activesupport (= 6.1.1)
|
16
|
+
activesupport (6.1.1)
|
18
17
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
19
|
-
i18n (>=
|
20
|
-
minitest (
|
21
|
-
tzinfo (~>
|
22
|
-
|
23
|
-
ast (2.4.
|
24
|
-
concurrent-ruby (1.1.
|
25
|
-
diff-lcs (1.
|
26
|
-
i18n (1.
|
18
|
+
i18n (>= 1.6, < 2)
|
19
|
+
minitest (>= 5.1)
|
20
|
+
tzinfo (~> 2.0)
|
21
|
+
zeitwerk (~> 2.3)
|
22
|
+
ast (2.4.1)
|
23
|
+
concurrent-ruby (1.1.7)
|
24
|
+
diff-lcs (1.4.4)
|
25
|
+
i18n (1.8.7)
|
27
26
|
concurrent-ruby (~> 1.0)
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
ast (~> 2.4.0)
|
27
|
+
minitest (5.14.3)
|
28
|
+
parallel (1.20.1)
|
29
|
+
parser (3.0.0.0)
|
30
|
+
ast (~> 2.4.1)
|
33
31
|
rainbow (3.0.0)
|
34
|
-
rake (
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
rspec-
|
39
|
-
|
40
|
-
rspec-
|
41
|
-
rspec-
|
32
|
+
rake (13.0.3)
|
33
|
+
regexp_parser (2.0.3)
|
34
|
+
rexml (3.2.4)
|
35
|
+
rspec (3.10.0)
|
36
|
+
rspec-core (~> 3.10.0)
|
37
|
+
rspec-expectations (~> 3.10.0)
|
38
|
+
rspec-mocks (~> 3.10.0)
|
39
|
+
rspec-core (3.10.1)
|
40
|
+
rspec-support (~> 3.10.0)
|
41
|
+
rspec-expectations (3.10.1)
|
42
42
|
diff-lcs (>= 1.2.0, < 2.0)
|
43
|
-
rspec-support (~> 3.
|
44
|
-
rspec-mocks (3.
|
43
|
+
rspec-support (~> 3.10.0)
|
44
|
+
rspec-mocks (3.10.1)
|
45
45
|
diff-lcs (>= 1.2.0, < 2.0)
|
46
|
-
rspec-support (~> 3.
|
47
|
-
rspec-support (3.
|
48
|
-
rubocop (
|
49
|
-
jaro_winkler (~> 1.5.1)
|
46
|
+
rspec-support (~> 3.10.0)
|
47
|
+
rspec-support (3.10.1)
|
48
|
+
rubocop (1.8.1)
|
50
49
|
parallel (~> 1.10)
|
51
|
-
parser (>=
|
50
|
+
parser (>= 3.0.0.0)
|
52
51
|
rainbow (>= 2.2.2, < 4.0)
|
52
|
+
regexp_parser (>= 1.8, < 3.0)
|
53
|
+
rexml
|
54
|
+
rubocop-ast (>= 1.2.0, < 2.0)
|
53
55
|
ruby-progressbar (~> 1.7)
|
54
|
-
unicode-display_width (>= 1.4.0, <
|
55
|
-
rubocop-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
56
|
+
unicode-display_width (>= 1.4.0, < 3.0)
|
57
|
+
rubocop-ast (1.4.0)
|
58
|
+
parser (>= 2.7.1.5)
|
59
|
+
rubocop-performance (1.9.2)
|
60
|
+
rubocop (>= 0.90.0, < 2.0)
|
61
|
+
rubocop-ast (>= 0.4.0)
|
62
|
+
rubocop-rake (0.5.1)
|
63
|
+
rubocop
|
64
|
+
rubocop-rspec (2.1.0)
|
65
|
+
rubocop (~> 1.0)
|
66
|
+
rubocop-ast (>= 1.1.0)
|
67
|
+
ruby-progressbar (1.11.0)
|
68
|
+
sqlite3 (1.4.2)
|
69
|
+
tzinfo (2.0.4)
|
70
|
+
concurrent-ruby (~> 1.0)
|
71
|
+
unicode-display_width (2.0.0)
|
72
|
+
zeitwerk (2.4.2)
|
63
73
|
|
64
74
|
PLATFORMS
|
65
75
|
ruby
|
@@ -70,8 +80,10 @@ DEPENDENCIES
|
|
70
80
|
rspec
|
71
81
|
rubocop
|
72
82
|
rubocop-performance
|
83
|
+
rubocop-rake
|
84
|
+
rubocop-rspec
|
73
85
|
searchable-by!
|
74
86
|
sqlite3
|
75
87
|
|
76
88
|
BUNDLED WITH
|
77
|
-
2.
|
89
|
+
2.1.4
|
data/LICENSE
CHANGED
data/README.md
CHANGED
@@ -14,8 +14,10 @@ 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
|
-
|
17
|
+
# Allow to search strings with custom match type.
|
18
|
+
# Use btree index-friendly prefix match, e.g. `ILIKE 'term%'` instead of default `ILIKE '%term%'`.
|
19
|
+
# For phrases use exact match type, e.g. searching for `"My Post"` will query `WHERE LOWER(title) = 'my post'`.
|
20
|
+
column :title, match: :prefix, match_phrase: :exact
|
19
21
|
|
20
22
|
# ... and integers.
|
21
23
|
column :id, type: :integer
|
data/lib/searchable_by.rb
CHANGED
@@ -1,132 +1,12 @@
|
|
1
1
|
require 'active_record'
|
2
|
-
require 'shellwords'
|
3
2
|
|
4
|
-
module
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
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'
|
9
8
|
|
10
|
-
|
11
|
-
@attr = attr
|
12
|
-
@type = type.to_sym
|
13
|
-
end
|
14
|
-
end
|
15
|
-
|
16
|
-
class Config
|
17
|
-
attr_reader :columns, :scoping
|
18
|
-
attr_accessor :max_terms
|
19
|
-
|
20
|
-
def initialize
|
21
|
-
@columns = []
|
22
|
-
@max_terms = 5
|
23
|
-
scope { all }
|
24
|
-
end
|
25
|
-
|
26
|
-
def initialize_copy(other)
|
27
|
-
@columns = other.columns.dup
|
28
|
-
super
|
29
|
-
end
|
30
|
-
|
31
|
-
def column(*attrs, &block)
|
32
|
-
opts = attrs.extract_options!
|
33
|
-
attrs.each do |attr|
|
34
|
-
columns.push Column.new(attr, opts)
|
35
|
-
end
|
36
|
-
columns.push Column.new(block, opts) if block
|
37
|
-
columns
|
38
|
-
end
|
39
|
-
|
40
|
-
def scope(&block)
|
41
|
-
@scoping = block
|
42
|
-
end
|
43
|
-
end
|
44
|
-
|
45
|
-
def self.norm_values(query)
|
46
|
-
values = Shellwords.split(query.to_s)
|
47
|
-
values.flatten!
|
48
|
-
values.reject!(&:blank?)
|
49
|
-
values.uniq!
|
50
|
-
values
|
51
|
-
end
|
52
|
-
|
53
|
-
def self.build_clauses(columns, values)
|
54
|
-
clauses = values.map do |value|
|
55
|
-
negate = value[0] == '-'
|
56
|
-
value.slice!(0) if negate || value[0] == '+'
|
57
|
-
|
58
|
-
grouping = columns.map do |column|
|
59
|
-
build_condition(column, value)
|
60
|
-
end
|
61
|
-
grouping.compact!
|
62
|
-
next if grouping.empty?
|
63
|
-
|
64
|
-
clause = grouping.inject(&:or)
|
65
|
-
clause = clause.not if negate
|
66
|
-
clause
|
67
|
-
end
|
68
|
-
clauses.compact!
|
69
|
-
clauses
|
70
|
-
end
|
71
|
-
|
72
|
-
def self.build_condition(column, value)
|
73
|
-
case column.type
|
74
|
-
when :int, :integer
|
75
|
-
begin
|
76
|
-
column.node.eq(Integer(value))
|
77
|
-
rescue ArgumentError
|
78
|
-
nil
|
79
|
-
end
|
80
|
-
else
|
81
|
-
value = value.dup
|
82
|
-
value.gsub!('%', '\%')
|
83
|
-
value.gsub!('_', '\_')
|
84
|
-
column.node.matches("%#{value}%")
|
85
|
-
end
|
86
|
-
end
|
87
|
-
|
88
|
-
module ClassMethods
|
89
|
-
def self.extended(base) # :nodoc:
|
90
|
-
base.class_attribute :_searchable_by_config, instance_accessor: false, instance_predicate: false
|
91
|
-
base._searchable_by_config = Config.new
|
92
|
-
super
|
93
|
-
end
|
94
|
-
|
95
|
-
def inherited(base) # :nodoc:
|
96
|
-
base._searchable_by_config = _searchable_by_config.dup
|
97
|
-
super
|
98
|
-
end
|
99
|
-
|
100
|
-
def searchable_by(max_terms: 5, &block)
|
101
|
-
_searchable_by_config.instance_eval(&block)
|
102
|
-
_searchable_by_config.max_terms = max_terms if max_terms
|
103
|
-
end
|
104
|
-
|
105
|
-
# @param [String] query the search query
|
106
|
-
# @return [ActiveRecord::Relation] the scoped relation
|
107
|
-
def search_by(query)
|
108
|
-
columns = _searchable_by_config.columns
|
109
|
-
return all if columns.empty?
|
110
|
-
|
111
|
-
values = SearchableBy.norm_values(query).first(_searchable_by_config.max_terms)
|
112
|
-
return all if values.empty?
|
113
|
-
|
114
|
-
columns.each do |col|
|
115
|
-
col.node ||= col.attr.is_a?(Proc) ? col.attr.call : arel_table[col.attr]
|
116
|
-
end
|
117
|
-
clauses = SearchableBy.build_clauses(columns, values)
|
118
|
-
return all if clauses.empty?
|
119
|
-
|
120
|
-
scope = instance_exec(&_searchable_by_config.scoping)
|
121
|
-
clauses.each do |clause|
|
122
|
-
scope = scope.where(clause)
|
123
|
-
end
|
124
|
-
scope
|
125
|
-
end
|
126
|
-
end
|
127
|
-
end
|
128
|
-
|
129
|
-
class Base
|
130
|
-
extend SearchableBy::ClassMethods
|
131
|
-
end
|
9
|
+
Value = Struct.new(:term, :negate, :phrase)
|
132
10
|
end
|
11
|
+
|
12
|
+
ActiveRecord::Base.extend SearchableBy::Concern if defined?(::ActiveRecord::Base)
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module SearchableBy
|
2
|
+
class Column
|
3
|
+
attr_reader :attr, :type, :match, :match_phrase
|
4
|
+
attr_accessor :node
|
5
|
+
|
6
|
+
def initialize(attr, type: :string, match: :all, match_phrase: nil)
|
7
|
+
@attr = attr
|
8
|
+
@type = type.to_sym
|
9
|
+
@match = match
|
10
|
+
@match_phrase = match_phrase || match
|
11
|
+
end
|
12
|
+
|
13
|
+
def build_condition(value)
|
14
|
+
scope = node.not_eq(nil)
|
15
|
+
|
16
|
+
case type
|
17
|
+
when :int, :integer
|
18
|
+
int_condition(scope, value)
|
19
|
+
else
|
20
|
+
str_condition(scope, value)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def int_condition(scope, value)
|
27
|
+
scope.and(node.eq(Integer(value.term)))
|
28
|
+
rescue ArgumentError
|
29
|
+
nil
|
30
|
+
end
|
31
|
+
|
32
|
+
def str_condition(scope, value)
|
33
|
+
term = value.term.dup
|
34
|
+
type = value.phrase ? match_phrase : match
|
35
|
+
|
36
|
+
case type
|
37
|
+
when :exact
|
38
|
+
term.downcase!
|
39
|
+
scope.and(node.lower.eq(term))
|
40
|
+
when :prefix
|
41
|
+
term.gsub!('%', '\%')
|
42
|
+
term.gsub!('_', '\_')
|
43
|
+
scope.and(node.matches("#{term}%"))
|
44
|
+
else
|
45
|
+
term.gsub!('%', '\%')
|
46
|
+
term.gsub!('_', '\_')
|
47
|
+
scope.and(node.matches("%#{term}%"))
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
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,6 +1,6 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = 'searchable-by'
|
3
|
-
s.version = '0.5.
|
3
|
+
s.version = '0.5.7'
|
4
4
|
s.authors = ['Dimitrij Denissenko']
|
5
5
|
s.email = ['dimitrij@blacksquaremedia.com']
|
6
6
|
s.summary = 'Generate search scopes'
|
@@ -8,10 +8,10 @@ Gem::Specification.new do |s|
|
|
8
8
|
s.homepage = 'https://github.com/bsm/sortable-by'
|
9
9
|
s.license = 'MIT'
|
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.5'
|
15
15
|
|
16
16
|
s.add_dependency 'activerecord'
|
17
17
|
s.add_dependency 'activesupport'
|
@@ -21,5 +21,7 @@ Gem::Specification.new do |s|
|
|
21
21
|
s.add_development_dependency 'rspec'
|
22
22
|
s.add_development_dependency 'rubocop'
|
23
23
|
s.add_development_dependency 'rubocop-performance'
|
24
|
+
s.add_development_dependency 'rubocop-rake'
|
25
|
+
s.add_development_dependency 'rubocop-rspec'
|
24
26
|
s.add_development_dependency 'sqlite3'
|
25
27
|
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,48 +1,72 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
describe
|
4
|
-
|
5
|
-
|
6
|
-
expect(Post.search_by(
|
7
|
-
expect(Post.search_by('').count).to eq(4)
|
3
|
+
describe SearchableBy do
|
4
|
+
it 'ignores bad inputs' do
|
5
|
+
expect(Post.search_by(nil).count).to eq(5)
|
6
|
+
expect(Post.search_by('').count).to eq(5)
|
8
7
|
end
|
9
8
|
|
10
|
-
it '
|
9
|
+
it 'configures correctly' do
|
11
10
|
expect(AbstractModel._searchable_by_config.columns.size).to eq(1)
|
12
|
-
expect(Post._searchable_by_config.columns.size).to eq(
|
11
|
+
expect(Post._searchable_by_config.columns.size).to eq(5)
|
13
12
|
end
|
14
13
|
|
15
|
-
it '
|
14
|
+
it 'generates SQL' do
|
16
15
|
sql = Post.search_by('123').to_sql
|
17
|
-
expect(sql).to include(%("posts"."id" = 123))
|
18
|
-
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'))
|
19
20
|
|
20
21
|
sql = Post.search_by('foo%bar').to_sql
|
21
22
|
expect(sql).not_to include(%("posts"."id"))
|
22
|
-
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
|
+
end
|
26
|
+
|
27
|
+
it 'searches' do
|
28
|
+
expect(Post.search_by('ALICE').pluck(:title)).to match_array(%w[ax1 ax2 ab1])
|
29
|
+
expect(Post.search_by('bOb').pluck(:title)).to match_array(%w[bx1 bx2 ab1])
|
23
30
|
end
|
24
31
|
|
25
|
-
it '
|
26
|
-
expect(Post.search_by('ALICE').pluck(:title)).to match_array(%w[
|
27
|
-
expect(Post.search_by('bOb').pluck(:title)).to match_array(%w[titlo titlu])
|
32
|
+
it 'searches across multiple words' do
|
33
|
+
expect(Post.search_by('ALICE your').pluck(:title)).to match_array(%w[ax2])
|
28
34
|
end
|
29
35
|
|
30
|
-
it '
|
31
|
-
expect(Post.search_by('
|
36
|
+
it 'supports search markers' do
|
37
|
+
expect(Post.search_by('aLiCe -your').pluck(:title)).to match_array(%w[ax1 ab1])
|
38
|
+
expect(Post.search_by('+alice "your recipe"').pluck(:title)).to match_array(%w[ax2])
|
39
|
+
expect(Post.search_by('bob -"her recipe"').pluck(:title)).to match_array(%w[bx2 ab1])
|
40
|
+
expect(Post.search_by('bob +"her recipe"').pluck(:title)).to match_array(%w[bx1])
|
32
41
|
end
|
33
42
|
|
34
|
-
it '
|
35
|
-
|
36
|
-
expect(Post.search_by('
|
37
|
-
expect(Post.search_by('
|
43
|
+
it 'respects match options' do
|
44
|
+
# name uses match: :exact
|
45
|
+
expect(Post.search_by('alice').pluck(:title)).to match_array(%w[ax1 ax2 ab1])
|
46
|
+
expect(Post.search_by('ali').pluck(:title)).to be_empty
|
47
|
+
expect(Post.search_by('lice').pluck(:title)).to be_empty
|
48
|
+
expect(Post.search_by('li').pluck(:title)).to be_empty
|
49
|
+
|
50
|
+
# title uses match: :prefix
|
51
|
+
expect(Post.search_by('ax').pluck(:title)).to match_array(%w[ax1 ax2])
|
52
|
+
expect(Post.search_by('bx').pluck(:title)).to match_array(%w[bx1 bx2])
|
53
|
+
expect(Post.search_by('ab').pluck(:title)).to match_array(%w[ab1])
|
54
|
+
expect(Post.search_by('ba').pluck(:title)).to be_empty
|
55
|
+
|
56
|
+
# title uses match_phrase: :exact
|
57
|
+
expect(Post.search_by('"ab"').pluck(:title)).to be_empty
|
58
|
+
expect(Post.search_by('"ab1"').pluck(:title)).to match_array(%w[ab1])
|
59
|
+
|
60
|
+
# body uses match: :all (default)
|
61
|
+
expect(Post.search_by('recip').pluck(:title)).to match_array(%w[ax1 ax2 bx1 bx2 ab1])
|
38
62
|
end
|
39
63
|
|
40
|
-
it '
|
41
|
-
expect(Post.where(title: '
|
42
|
-
expect(Post.where(title: '
|
64
|
+
it 'searches within scopes' do
|
65
|
+
expect(Post.where(title: 'ax1').search_by('ALICE').pluck(:title)).to match_array(%w[ax1])
|
66
|
+
expect(Post.where(title: 'ax1').search_by('bOb').pluck(:title)).to be_empty
|
43
67
|
end
|
44
68
|
|
45
|
-
it '
|
46
|
-
expect(Post.search_by(POSTS[:
|
69
|
+
it 'searches integers' do
|
70
|
+
expect(Post.search_by(POSTS[:ab1].id.to_s).count).to eq(1)
|
47
71
|
end
|
48
72
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -2,15 +2,16 @@ ENV['RACK_ENV'] ||= 'test'
|
|
2
2
|
require 'searchable-by'
|
3
3
|
require 'rspec'
|
4
4
|
|
5
|
-
ActiveRecord::Base.configurations
|
5
|
+
ActiveRecord::Base.configurations = { 'test' => { 'adapter' => 'sqlite3', 'database' => ':memory:' } }
|
6
6
|
ActiveRecord::Base.establish_connection :test
|
7
7
|
|
8
8
|
ActiveRecord::Base.connection.instance_eval do
|
9
|
-
create_table :
|
9
|
+
create_table :users do |t|
|
10
10
|
t.string :name
|
11
11
|
end
|
12
12
|
create_table :posts do |t|
|
13
13
|
t.integer :author_id, null: false
|
14
|
+
t.integer :reviewer_id
|
14
15
|
t.string :title
|
15
16
|
t.text :body
|
16
17
|
end
|
@@ -24,31 +25,35 @@ class AbstractModel < ActiveRecord::Base
|
|
24
25
|
end
|
25
26
|
end
|
26
27
|
|
27
|
-
class
|
28
|
-
has_many :posts
|
28
|
+
class User < AbstractModel
|
29
|
+
has_many :posts, foreign_key: :author_id
|
29
30
|
end
|
30
31
|
|
31
32
|
class Post < AbstractModel
|
32
|
-
belongs_to :author
|
33
|
+
belongs_to :author, class_name: 'User'
|
34
|
+
belongs_to :reviewer, class_name: 'User'
|
33
35
|
|
34
36
|
searchable_by do
|
35
|
-
column :title, :
|
36
|
-
column
|
37
|
+
column :title, match: :prefix, match_phrase: :exact
|
38
|
+
column :body
|
39
|
+
column proc { User.arel_table[:name] }, match: :exact
|
40
|
+
column { User.arel_table.alias('reviewers_posts')[:name] }
|
37
41
|
|
38
42
|
scope do
|
39
|
-
joins(:author)
|
43
|
+
joins(:author).left_outer_joins(:reviewer)
|
40
44
|
end
|
41
45
|
end
|
42
46
|
end
|
43
47
|
|
44
|
-
|
45
|
-
|
46
|
-
|
48
|
+
USERS = {
|
49
|
+
a: User.create!(name: 'Alice'),
|
50
|
+
b: User.create!(name: 'Bob'),
|
47
51
|
}.freeze
|
48
52
|
|
49
53
|
POSTS = {
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
+
ax1: USERS[:a].posts.create!(title: 'ax1', body: 'my recipe '),
|
55
|
+
ax2: USERS[:a].posts.create!(title: 'ax2', body: 'your recipe'),
|
56
|
+
bx1: USERS[:b].posts.create!(title: 'bx1', body: 'her recipe'),
|
57
|
+
bx2: USERS[:b].posts.create!(title: 'bx2', body: 'our recipe'),
|
58
|
+
ab1: USERS[:a].posts.create!(title: 'ab1', reviewer: USERS[:b], body: 'their recipe'),
|
54
59
|
}.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.7
|
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-01-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -108,6 +108,34 @@ dependencies:
|
|
108
108
|
- - ">="
|
109
109
|
- !ruby/object:Gem::Version
|
110
110
|
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rubocop-rake
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: rubocop-rspec
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
111
139
|
- !ruby/object:Gem::Dependency
|
112
140
|
name: sqlite3
|
113
141
|
requirement: !ruby/object:Gem::Requirement
|
@@ -139,7 +167,12 @@ files:
|
|
139
167
|
- Rakefile
|
140
168
|
- lib/searchable-by.rb
|
141
169
|
- lib/searchable_by.rb
|
170
|
+
- lib/searchable_by/column.rb
|
171
|
+
- lib/searchable_by/concern.rb
|
172
|
+
- lib/searchable_by/config.rb
|
173
|
+
- lib/searchable_by/util.rb
|
142
174
|
- searchable-by.gemspec
|
175
|
+
- spec/searchable_by/util_spec.rb
|
143
176
|
- spec/searchable_by_spec.rb
|
144
177
|
- spec/spec_helper.rb
|
145
178
|
homepage: https://github.com/bsm/sortable-by
|
@@ -154,17 +187,18 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
154
187
|
requirements:
|
155
188
|
- - ">="
|
156
189
|
- !ruby/object:Gem::Version
|
157
|
-
version: '2.
|
190
|
+
version: '2.5'
|
158
191
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
159
192
|
requirements:
|
160
193
|
- - ">="
|
161
194
|
- !ruby/object:Gem::Version
|
162
195
|
version: '0'
|
163
196
|
requirements: []
|
164
|
-
rubygems_version: 3.
|
197
|
+
rubygems_version: 3.1.4
|
165
198
|
signing_key:
|
166
199
|
specification_version: 4
|
167
200
|
summary: Generate search scopes
|
168
201
|
test_files:
|
202
|
+
- spec/searchable_by/util_spec.rb
|
169
203
|
- spec/searchable_by_spec.rb
|
170
204
|
- spec/spec_helper.rb
|