searchable-by 0.5.5 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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