searchable-by 0.5.5 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b2c3092a9a334ae2ea2256953e9a1fa3198d6b6aa8d8f5fe7eb841920782dce
4
- data.tar.gz: 6e6ba3ee470d96063a91e24c3fdcbe2be411a13a567828b57c39d74dc1a8133a
3
+ metadata.gz: 615602791390c9de0ab159cbbd0c81c52c0441f61ad559082dad189787c7606f
4
+ data.tar.gz: ad765f8a22cd76196f7a04b7a7e16f38b3bbccacda0f1c7bda659e3ab2783f4f
5
5
  SHA512:
6
- metadata.gz: 52a694bb37c67c2f4a8905e41d0affa729f1ddf0a595428278af25f8d97e986fe6bb5aa8cb462932dee1258032f4f3adb8b2f4f1c804f3c049482b7ef64493b0
7
- data.tar.gz: ea1c883f1f9636c0baf7575918bf89330b22503efc132a65084a49a6e9ff63c96313efcc69259d4e26175b8f75744a65b8f6c39bfb130a9e4532a189aa19ed3f
6
+ metadata.gz: ae524aa23d507f4339592c4c6a2955d3d0664e7f9829be7071ed1d28a065e1ad77ff9a810a800769cf417db4d315344a487b058530e168325403cb26a1832e12
7
+ data.tar.gz: 71a1349d3738997571306d5326cf81bef66b101bbd03b9451d3b3d9b5851c35f1d732486d96b29d73e1efce792a0c5c840c42442a2f83bd5be171a47be28a17c
@@ -0,0 +1,21 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ ruby:
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
- require: rubocop-performance
2
- inherit_from:
3
- - https://gitlab.com/bsm/misc/raw/master/rubocop/default.yml
1
+ inherit_gem:
2
+ rubocop-bsm:
3
+ - default.yml
4
+ inherit_mode:
5
+ merge:
6
+ - Exclude
4
7
 
5
8
  AllCops:
6
- TargetRubyVersion: "2.5"
9
+ TargetRubyVersion: "2.6"
7
10
 
8
11
  Naming/FileName:
9
12
  Exclude:
data/Gemfile.lock CHANGED
@@ -1,68 +1,79 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- searchable-by (0.5.5)
4
+ searchable-by (0.6.0)
5
5
  activerecord
6
- activesupport
7
6
 
8
7
  GEM
9
8
  remote: http://rubygems.org/
10
9
  specs:
11
- activemodel (6.0.3.1)
12
- activesupport (= 6.0.3.1)
13
- activerecord (6.0.3.1)
14
- activemodel (= 6.0.3.1)
15
- activesupport (= 6.0.3.1)
16
- activesupport (6.0.3.1)
10
+ activemodel (6.1.4)
11
+ activesupport (= 6.1.4)
12
+ activerecord (6.1.4)
13
+ activemodel (= 6.1.4)
14
+ activesupport (= 6.1.4)
15
+ activesupport (6.1.4)
17
16
  concurrent-ruby (~> 1.0, >= 1.0.2)
18
- i18n (>= 0.7, < 2)
19
- minitest (~> 5.1)
20
- tzinfo (~> 1.1)
21
- zeitwerk (~> 2.2, >= 2.2.2)
22
- ast (2.4.0)
23
- concurrent-ruby (1.1.6)
24
- diff-lcs (1.3)
25
- i18n (1.8.2)
17
+ i18n (>= 1.6, < 2)
18
+ minitest (>= 5.1)
19
+ tzinfo (~> 2.0)
20
+ zeitwerk (~> 2.3)
21
+ ast (2.4.2)
22
+ concurrent-ruby (1.1.9)
23
+ diff-lcs (1.4.4)
24
+ i18n (1.8.10)
26
25
  concurrent-ruby (~> 1.0)
27
- minitest (5.14.1)
28
- parallel (1.19.1)
29
- parser (2.7.1.3)
30
- ast (~> 2.4.0)
26
+ minitest (5.14.4)
27
+ parallel (1.20.1)
28
+ parser (3.0.2.0)
29
+ ast (~> 2.4.1)
31
30
  rainbow (3.0.0)
32
- rake (13.0.1)
33
- rexml (3.2.4)
34
- rspec (3.9.0)
35
- rspec-core (~> 3.9.0)
36
- rspec-expectations (~> 3.9.0)
37
- rspec-mocks (~> 3.9.0)
38
- rspec-core (3.9.2)
39
- rspec-support (~> 3.9.3)
40
- rspec-expectations (3.9.2)
31
+ rake (13.0.6)
32
+ regexp_parser (2.1.1)
33
+ rexml (3.2.5)
34
+ rspec (3.10.0)
35
+ rspec-core (~> 3.10.0)
36
+ rspec-expectations (~> 3.10.0)
37
+ rspec-mocks (~> 3.10.0)
38
+ rspec-core (3.10.1)
39
+ rspec-support (~> 3.10.0)
40
+ rspec-expectations (3.10.1)
41
41
  diff-lcs (>= 1.2.0, < 2.0)
42
- rspec-support (~> 3.9.0)
43
- rspec-mocks (3.9.1)
42
+ rspec-support (~> 3.10.0)
43
+ rspec-mocks (3.10.2)
44
44
  diff-lcs (>= 1.2.0, < 2.0)
45
- rspec-support (~> 3.9.0)
46
- rspec-support (3.9.3)
47
- rubocop (0.84.0)
45
+ rspec-support (~> 3.10.0)
46
+ rspec-support (3.10.2)
47
+ rubocop (1.18.3)
48
48
  parallel (~> 1.10)
49
- parser (>= 2.7.0.1)
49
+ parser (>= 3.0.0.0)
50
50
  rainbow (>= 2.2.2, < 4.0)
51
+ regexp_parser (>= 1.8, < 3.0)
51
52
  rexml
52
- rubocop-ast (>= 0.0.3)
53
+ rubocop-ast (>= 1.7.0, < 2.0)
53
54
  ruby-progressbar (~> 1.7)
54
- unicode-display_width (>= 1.4.0, < 2.0)
55
- rubocop-ast (0.0.3)
56
- parser (>= 2.7.0.1)
57
- rubocop-performance (1.6.0)
58
- rubocop (>= 0.71.0)
59
- ruby-progressbar (1.10.1)
55
+ unicode-display_width (>= 1.4.0, < 3.0)
56
+ rubocop-ast (1.7.0)
57
+ parser (>= 3.0.1.1)
58
+ rubocop-bsm (0.6.0)
59
+ rubocop (~> 1.0)
60
+ rubocop-performance
61
+ rubocop-rake
62
+ rubocop-rspec
63
+ rubocop-performance (1.11.4)
64
+ rubocop (>= 1.7.0, < 2.0)
65
+ rubocop-ast (>= 0.4.0)
66
+ rubocop-rake (0.6.0)
67
+ rubocop (~> 1.0)
68
+ rubocop-rspec (2.4.0)
69
+ rubocop (~> 1.0)
70
+ rubocop-ast (>= 1.1.0)
71
+ ruby-progressbar (1.11.0)
60
72
  sqlite3 (1.4.2)
61
- thread_safe (0.3.6)
62
- tzinfo (1.2.7)
63
- thread_safe (~> 0.1)
64
- unicode-display_width (1.7.0)
65
- zeitwerk (2.3.0)
73
+ tzinfo (2.0.4)
74
+ concurrent-ruby (~> 1.0)
75
+ unicode-display_width (2.0.0)
76
+ zeitwerk (2.4.2)
66
77
 
67
78
  PLATFORMS
68
79
  ruby
@@ -72,9 +83,9 @@ DEPENDENCIES
72
83
  rake
73
84
  rspec
74
85
  rubocop
75
- rubocop-performance
86
+ rubocop-bsm
76
87
  searchable-by!
77
88
  sqlite3
78
89
 
79
90
  BUNDLED WITH
80
- 2.1.2
91
+ 2.2.21
data/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright 2019 Black Square Media Ltd
1
+ Copyright 2020 Black Square Media Ltd
2
2
 
3
3
  Licensed under the Apache License, Version 2.0 (the "License");
4
4
  you may not use this file except in compliance with the License.
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Searchable By
2
2
 
3
+ [![Test](https://github.com/bsm/searchable-by/actions/workflows/test.yml/badge.svg)](https://github.com/bsm/searchable-by/actions/workflows/test.yml)
4
+
3
5
  ActiveRecord plugin to quickly create search scopes.
4
6
 
5
7
  ## Installation
@@ -13,9 +15,12 @@ class Post < ActiveRecord::Base
13
15
  belongs_to :author
14
16
 
15
17
  # Limit the number of terms per query to 3.
16
- searchable_by max_terms: 3 do
17
- # Allow to search strings.
18
- column :title
18
+ # Ignore search terms shorter than 3 characters (useful for trigram indexes).
19
+ searchable_by max_terms: 3, min_length: 3 do
20
+ # Allow to search strings with custom match type.
21
+ column :title,
22
+ match: :prefix, # Use btree index-friendly prefix match, e.g. `ILIKE 'term%'` instead of default `ILIKE '%term%'`.
23
+ match_phrase: :exact, # For phrases use exact match type, e.g. searching for `"My Post"` will query `WHERE LOWER(title) = 'my post'`.
19
24
 
20
25
  # ... and integers.
21
26
  column :id, type: :integer
data/lib/searchable_by.rb CHANGED
@@ -1,149 +1,12 @@
1
1
  require 'active_record'
2
2
 
3
- module ActiveRecord
4
- module SearchableBy
5
- class Column
6
- attr_reader :attr, :type
7
- attr_accessor :node
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
- def initialize(attr, type: :string)
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,66 @@
1
+ module SearchableBy
2
+ class Column
3
+ attr_reader :attr, :type, :match, :match_phrase, :wildcard
4
+ attr_accessor :node
5
+
6
+ def initialize(attr, type: :string, match: :all, match_phrase: nil, wildcard: nil, **opts) # rubocop:disable Metrics/ParameterLists
7
+ if opts.key?(:min_length)
8
+ ActiveSupport::Deprecation.warn(
9
+ 'Setting min_length for individual columns is deprecated and will be removed in the next release.' \
10
+ 'Please pass it as an option to searchable_by instead',
11
+ )
12
+ end
13
+
14
+ @attr = attr
15
+ @type = type.to_sym
16
+ @match = match
17
+ @match_phrase = match_phrase || match
18
+ @min_length = opts[:min_length].to_i
19
+ @wildcard = wildcard
20
+ end
21
+
22
+ def build_condition(value)
23
+ scope = node.not_eq(nil)
24
+
25
+ case type
26
+ when :int, :integer
27
+ int_condition(scope, value)
28
+ else
29
+ str_condition(scope, value)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # TODO: remove when removing min_length option from columns
36
+ def usable?(value)
37
+ value.term.length >= @min_length
38
+ end
39
+
40
+ def int_condition(scope, value)
41
+ scope.and(node.eq(Integer(value.term)))
42
+ rescue ArgumentError
43
+ nil
44
+ end
45
+
46
+ def str_condition(scope, value)
47
+ term = value.term.dup
48
+ type = value.phrase ? match_phrase : match
49
+
50
+ case type
51
+ when :exact
52
+ term.downcase!
53
+ scope.and(node.lower.eq(term))
54
+ when :prefix
55
+ term.gsub!('%', '\%')
56
+ term.gsub!('_', '\_')
57
+ scope.and(node.matches("#{term}%"))
58
+ else
59
+ term.gsub!('%', '\%')
60
+ term.gsub!('_', '\_')
61
+ term.gsub!(wildcard, '%') if wildcard
62
+ scope.and(node.matches("%#{term}%"))
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,45 @@
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, min_length: 0, **options, &block)
15
+ _searchable_by_config.instance_eval(&block)
16
+ _searchable_by_config.max_terms = max_terms if max_terms
17
+ _searchable_by_config.min_length = min_length
18
+ _searchable_by_config.options.update(options) unless options.empty?
19
+ _searchable_by_config
20
+ end
21
+
22
+ # @param [String] query the search query
23
+ # @return [ActiveRecord::Relation] the scoped relation
24
+ def search_by(query)
25
+ config = _searchable_by_config
26
+ columns = config.columns
27
+ return all if columns.empty?
28
+
29
+ values = Util.norm_values(query, min_length: config.min_length).first(config.max_terms)
30
+ return all if values.empty?
31
+
32
+ columns.each do |col|
33
+ col.node ||= col.attr.is_a?(Proc) ? col.attr.call : arel_table[col.attr]
34
+ end
35
+ clauses = Util.build_clauses(columns, values)
36
+ return all if clauses.empty?
37
+
38
+ scope = instance_exec(&config.scoping)
39
+ clauses.each do |clause|
40
+ scope = scope.where(clause)
41
+ end
42
+ scope
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,32 @@
1
+ module SearchableBy
2
+ class Config
3
+ attr_reader :columns, :scoping, :options
4
+ attr_accessor :max_terms, :min_length
5
+
6
+ def initialize
7
+ @columns = []
8
+ @max_terms = 5
9
+ @min_length = 0
10
+ @options = {}
11
+ scope { all }
12
+ end
13
+
14
+ def initialize_copy(other)
15
+ @columns = other.columns.dup
16
+ super
17
+ end
18
+
19
+ def column(*attrs, &block)
20
+ opts = attrs.extract_options!
21
+ attrs.each do |attr|
22
+ columns.push Column.new(attr, **@options, **opts)
23
+ end
24
+ columns.push Column.new(block, **@options, **opts) if block
25
+ columns
26
+ end
27
+
28
+ def scope(&block)
29
+ @scoping = block
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,49 @@
1
+ module SearchableBy
2
+ module Util
3
+ def self.norm_values(query, min_length: 0)
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? || term.length < min_length
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? || term.length < min_length
24
+ end
25
+
26
+ values.uniq!
27
+ values
28
+ end
29
+
30
+ def self.build_clauses(columns, values)
31
+ values.map do |value|
32
+ # TODO: remove when removing min_length option from columns
33
+ usable = columns.all? do |column|
34
+ column.send(:usable?, value)
35
+ end
36
+ next unless usable
37
+
38
+ group = columns.map do |column|
39
+ column.build_condition(value)
40
+ end.tap(&:compact!)
41
+ next if group.empty?
42
+
43
+ clause = group.inject(&:or)
44
+ clause = clause.not if value.negate
45
+ clause
46
+ end.tap(&:compact!)
47
+ end
48
+ end
49
+ end
@@ -1,25 +1,23 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = 'searchable-by'
3
- s.version = '0.5.5'
3
+ s.version = '0.6.0'
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 = 'MIT'
9
+ s.license = 'Apache-2.0'
10
10
 
11
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.5'
14
+ s.required_ruby_version = '>= 2.6'
15
15
 
16
16
  s.add_dependency 'activerecord'
17
- s.add_dependency 'activesupport'
18
-
19
17
  s.add_development_dependency 'bundler'
20
18
  s.add_development_dependency 'rake'
21
19
  s.add_development_dependency 'rspec'
22
20
  s.add_development_dependency 'rubocop'
23
- s.add_development_dependency 'rubocop-performance'
21
+ s.add_development_dependency 'rubocop-bsm'
24
22
  s.add_development_dependency 'sqlite3'
25
23
  end
@@ -0,0 +1,36 @@
1
+ require 'spec_helper'
2
+
3
+ describe SearchableBy::Util do
4
+ context 'with norm_values' do
5
+ def norm(str, **opts)
6
+ described_class.norm_values(str, **opts).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
+ expect(norm('with min length', min_length: 4)).to eq('length' => false, 'with' => false)
34
+ end
35
+ end
36
+ end
@@ -1,80 +1,96 @@
1
1
  require 'spec_helper'
2
2
 
3
- describe ActiveRecord::SearchableBy do
4
- context '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 '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 'should configure correctly' do
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 'should generate SQL' do
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 '%123%'))
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 '%foo\\%bar%'))
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%'))
31
+ end
32
+
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])
54
40
  end
55
41
 
56
- it 'should search' do
57
- expect(Post.search_by('ALICE').pluck(:title)).to match_array(%w[a1 a2 ab])
58
- expect(Post.search_by('bOb').pluck(:title)).to match_array(%w[b1 b2 ab])
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])
59
47
  end
60
48
 
61
- it 'should search across multiple words' do
62
- expect(Post.search_by('ALICE your').pluck(:title)).to match_array(%w[a2])
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])
68
+ end
69
+
70
+ it 'supports min term length in context' do
71
+ # values are discarded - too short for bio
72
+ expect(User.search_by('+be')).to match_array(USERS.values_at(:a, :b))
73
+ expect(User.search_by('is')).to match_array(USERS.values_at(:a, :b))
74
+
75
+ # value is used to scope
76
+ expect(User.search_by('ear')).to match_array(USERS.values_at(:a))
77
+
78
+ # one used, one discarded
79
+ expect(User.search_by('beard is')).to match_array(USERS.values_at(:a))
63
80
  end
64
81
 
65
- it 'should support search markers' do
66
- expect(Post.search_by('aLiCe -your').pluck(:title)).to match_array(%w[a1 ab])
67
- expect(Post.search_by('+alice "your recipe"').pluck(:title)).to match_array(%w[a2])
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])
82
+ it 'searches within scopes' do
83
+ expect(Post.where(title: 'ax1').search_by('ALICE').pluck(:title)).to match_array(%w[ax1])
84
+ expect(Post.where(title: 'ax1').search_by('bOb').pluck(:title)).to be_empty
70
85
  end
71
86
 
72
- it 'should search within scopes' do
73
- expect(Post.where(title: 'a1').search_by('ALICE').pluck(:title)).to match_array(%w[a1])
74
- expect(Post.where(title: 'a1').search_by('bOb').pluck(:title)).to match_array(%w[])
87
+ it 'searches integers' do
88
+ expect(Post.search_by(POSTS[:ab1].id.to_s).count).to eq(1)
75
89
  end
76
90
 
77
- it 'should search integers' do
78
- expect(Post.search_by(POSTS[:ab].id.to_s).count).to eq(1)
91
+ it 'supports wildcard searching' do
92
+ expect(User.search_by('uni*dom')).to match_array(USERS.values_at(:a))
93
+ expect(User.search_by('uni*o')).to match_array(USERS.values_at(:a, :b))
94
+ expect(User.search_by('uni*of*dom')).to be_empty
79
95
  end
80
96
  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 min_length: 3 do
34
+ column :bio
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, :body
38
- column { User.arel_table[:name] }
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: "Chuck Norris' beard is immutable.", country: 'United Kingdom'),
57
+ b: User.create!(name: 'Bob', bio: 'Chuck Norris can divide by zero.', country: 'United States of America'),
50
58
  }.freeze
51
59
 
52
60
  POSTS = {
53
- a1: USERS[:a].posts.create!(title: 'a1', body: 'my recipe '),
54
- a2: USERS[:a].posts.create!(title: 'a2', body: 'your recipe'),
55
- b1: USERS[:b].posts.create!(title: 'b1', body: 'her recipe'),
56
- b2: USERS[:b].posts.create!(title: 'b2', body: 'our recipe'),
57
- ab: USERS[:a].posts.create!(title: 'ab', reviewer: USERS[:b], body: 'their recipe'),
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.5
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dimitrij Denissenko
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-05-27 00:00:00.000000000 Z
11
+ date: 2021-07-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -24,20 +24,6 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
- - !ruby/object:Gem::Dependency
28
- name: activesupport
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '0'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - ">="
39
- - !ruby/object:Gem::Version
40
- version: '0'
41
27
  - !ruby/object:Gem::Dependency
42
28
  name: bundler
43
29
  requirement: !ruby/object:Gem::Requirement
@@ -95,7 +81,7 @@ dependencies:
95
81
  - !ruby/object:Gem::Version
96
82
  version: '0'
97
83
  - !ruby/object:Gem::Dependency
98
- name: rubocop-performance
84
+ name: rubocop-bsm
99
85
  requirement: !ruby/object:Gem::Requirement
100
86
  requirements:
101
87
  - - ">="
@@ -129,9 +115,9 @@ executables: []
129
115
  extensions: []
130
116
  extra_rdoc_files: []
131
117
  files:
118
+ - ".github/workflows/test.yml"
132
119
  - ".gitignore"
133
120
  - ".rubocop.yml"
134
- - ".travis.yml"
135
121
  - Gemfile
136
122
  - Gemfile.lock
137
123
  - LICENSE
@@ -139,12 +125,17 @@ files:
139
125
  - Rakefile
140
126
  - lib/searchable-by.rb
141
127
  - lib/searchable_by.rb
128
+ - lib/searchable_by/column.rb
129
+ - lib/searchable_by/concern.rb
130
+ - lib/searchable_by/config.rb
131
+ - lib/searchable_by/util.rb
142
132
  - searchable-by.gemspec
133
+ - spec/searchable_by/util_spec.rb
143
134
  - spec/searchable_by_spec.rb
144
135
  - spec/spec_helper.rb
145
136
  homepage: https://github.com/bsm/sortable-by
146
137
  licenses:
147
- - MIT
138
+ - Apache-2.0
148
139
  metadata: {}
149
140
  post_install_message:
150
141
  rdoc_options: []
@@ -154,17 +145,18 @@ required_ruby_version: !ruby/object:Gem::Requirement
154
145
  requirements:
155
146
  - - ">="
156
147
  - !ruby/object:Gem::Version
157
- version: '2.5'
148
+ version: '2.6'
158
149
  required_rubygems_version: !ruby/object:Gem::Requirement
159
150
  requirements:
160
151
  - - ">="
161
152
  - !ruby/object:Gem::Version
162
153
  version: '0'
163
154
  requirements: []
164
- rubygems_version: 3.1.2
155
+ rubygems_version: 3.2.15
165
156
  signing_key:
166
157
  specification_version: 4
167
158
  summary: Generate search scopes
168
159
  test_files:
160
+ - spec/searchable_by/util_spec.rb
169
161
  - spec/searchable_by_spec.rb
170
162
  - spec/spec_helper.rb
data/.travis.yml DELETED
@@ -1,7 +0,0 @@
1
- language: ruby
2
- rvm:
3
- - 2.6
4
- - 2.5
5
- cache: bundler
6
- before_install:
7
- - gem install bundler