bharat_filter_engine 0.1.1 → 0.2.0pre

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.
@@ -2,46 +2,104 @@
2
2
 
3
3
  module BharatFilterEngine
4
4
  class RuleNormalizer
5
+ KNOWN_FILTER_TYPES = %i[
6
+ string
7
+ array
8
+ boolean
9
+ integer
10
+ float
11
+ daterange
12
+ search
13
+ presence
14
+ ].freeze
15
+
16
+ KNOWN_RANGE_TYPES = %i[gte lte between].freeze
17
+ NUMERIC_FILTER_TYPES = %i[integer float].freeze
18
+ NEGATE_UNSUPPORTED_FILTER_TYPES = %i[daterange search presence].freeze
19
+
5
20
  def self.call(rule)
6
21
  return nil unless rule
7
22
 
8
- rule = rule.transform_keys(&:to_sym)
23
+ normalized = normalize(rule.transform_keys(&:to_sym))
24
+ validate!(normalized)
25
+ normalized
26
+ end
9
27
 
28
+ def self.normalize(rule)
10
29
  return rule if rule[:filter_type].present?
11
30
 
12
- # Legacy configuration support.
13
- #
14
- # Old:
15
- # {
16
- # type: :string,
17
- # dbcolumn: :stage
18
- # }
19
- #
20
- # New:
21
- # {
22
- # type: :single,
23
- # filter_type: :string,
24
- # dbcolumn: :stage
25
- # }
26
-
27
- scope_type =
28
- if rule[:type] == :nested
29
- :nested
30
- else
31
- :single
32
- end
33
-
34
- filter_type =
35
- if rule[:type] == :nested
36
- rule[:filter_type]
37
- else
38
- rule[:type]
39
- end
31
+ scope_type = rule[:type] == :nested ? :nested : :single
32
+ filter_type = rule[:type] == :nested ? rule[:filter_type] : rule[:type]
40
33
 
41
34
  rule.merge(
42
35
  type: scope_type,
43
36
  filter_type: filter_type
44
37
  )
45
38
  end
39
+ private_class_method :normalize
40
+
41
+ def self.validate!(rule)
42
+ filter_type = rule[:filter_type]
43
+
44
+ unless KNOWN_FILTER_TYPES.include?(filter_type)
45
+ raise InvalidConfigurationError,
46
+ "BharatFilterEngine: unknown filter_type #{filter_type.inspect}. " \
47
+ "Expected one of #{KNOWN_FILTER_TYPES.inspect}."
48
+ end
49
+
50
+ if rule[:type] == :nested && rule[:association].blank?
51
+ raise InvalidConfigurationError,
52
+ 'BharatFilterEngine: a `type: :nested` filter rule requires ' \
53
+ 'an `association:` key.'
54
+ end
55
+
56
+ if rule[:type] == :nested && filter_type == :search
57
+ raise InvalidConfigurationError,
58
+ 'BharatFilterEngine: `filter_type: :search` does not support ' \
59
+ '`type: :nested` -- use `__` notation inside `dbcolumns:` instead.'
60
+ end
61
+
62
+ if filter_type == :search
63
+ if Array(rule[:dbcolumns]).empty?
64
+ raise InvalidConfigurationError,
65
+ 'BharatFilterEngine: a `filter_type: :search` rule requires ' \
66
+ 'a non-empty `dbcolumns:` array.'
67
+ end
68
+ elsif rule[:dbcolumn].blank?
69
+ raise InvalidConfigurationError,
70
+ 'BharatFilterEngine: filter rule requires a `dbcolumn:` key.'
71
+ end
72
+
73
+ validate_range_type!(rule, filter_type)
74
+ validate_negate!(rule, filter_type)
75
+ end
76
+ private_class_method :validate!
77
+
78
+ def self.validate_range_type!(rule, filter_type)
79
+ return unless rule[:range_type]
80
+
81
+ unless KNOWN_RANGE_TYPES.include?(rule[:range_type])
82
+ raise InvalidConfigurationError,
83
+ "BharatFilterEngine: unknown range_type #{rule[:range_type].inspect}. " \
84
+ "Expected one of #{KNOWN_RANGE_TYPES.inspect}."
85
+ end
86
+
87
+ return if NUMERIC_FILTER_TYPES.include?(filter_type)
88
+
89
+ raise InvalidConfigurationError,
90
+ 'BharatFilterEngine: `range_type:` is only supported for ' \
91
+ "#{NUMERIC_FILTER_TYPES.inspect} filters, got #{filter_type.inspect}."
92
+ end
93
+ private_class_method :validate_range_type!
94
+
95
+ def self.validate_negate!(rule, filter_type)
96
+ return unless rule[:negate]
97
+ return if rule[:range_type].blank? && !NEGATE_UNSUPPORTED_FILTER_TYPES.include?(filter_type)
98
+
99
+ raise InvalidConfigurationError,
100
+ 'BharatFilterEngine: `negate: true` is not supported together ' \
101
+ "with `range_type:`, or with #{NEGATE_UNSUPPORTED_FILTER_TYPES.inspect} filters."
102
+ end
103
+ private_class_method :validate_negate!
46
104
  end
47
- end
105
+ end
@@ -2,10 +2,21 @@
2
2
 
3
3
  module BharatFilterEngine
4
4
  class SearchBuilder
5
+ LIKE_ESCAPE_CHAR = '\\'
6
+
5
7
  def initialize(scope:, allowed_columns:)
6
8
  @scope = scope
7
9
  @allowed_columns =
8
10
  Array(allowed_columns).map(&:to_s)
11
+
12
+ if @allowed_columns.present? &&
13
+ @allowed_columns.none? { |field| valid_field?(field) }
14
+
15
+ raise InvalidConfigurationError,
16
+ 'BharatFilterEngine: none of the configured allowed_columns ' \
17
+ "#{@allowed_columns.inspect} could be resolved on " \
18
+ "#{@scope.klass}. Check `dbcolumns:` in your filter config."
19
+ end
9
20
  end
10
21
 
11
22
  def apply(value)
@@ -13,7 +24,7 @@ module BharatFilterEngine
13
24
 
14
25
  search = value.to_s.strip
15
26
 
16
- if search.include?("=")
27
+ if search.include?('=')
17
28
  field_based_search(search)
18
29
  else
19
30
  simple_search(search)
@@ -23,13 +34,13 @@ module BharatFilterEngine
23
34
  private
24
35
 
25
36
  def simple_search(search)
26
- query = "%#{search}%"
37
+ query = like_query(search)
27
38
  conditions = []
28
39
 
29
40
  @allowed_columns.each do |dbcolumn|
30
41
  next unless valid_field?(dbcolumn)
31
42
 
32
- if dbcolumn.include?("__")
43
+ if dbcolumn.include?('__')
33
44
  @scope, condition =
34
45
  nested_condition(
35
46
  @scope,
@@ -41,7 +52,7 @@ module BharatFilterEngine
41
52
  else
42
53
  table = @scope.klass.arel_table
43
54
 
44
- conditions << table[dbcolumn].matches(query)
55
+ conditions << table[dbcolumn].matches(query, LIKE_ESCAPE_CHAR)
45
56
  end
46
57
  end
47
58
 
@@ -60,19 +71,19 @@ module BharatFilterEngine
60
71
  # is skipped and unrelated records leak through unfiltered.
61
72
  def field_based_search(search)
62
73
  and_conditions =
63
- search.split("&").map do |and_group|
74
+ search.split('&').map do |and_group|
64
75
  or_conditions =
65
- and_group.split("|").filter_map do |expression|
76
+ and_group.split('|').filter_map do |expression|
66
77
  field, search_value =
67
- expression.split("=", 2)
78
+ expression.split('=', 2)
68
79
 
69
80
  next if field.blank?
70
81
  next if search_value.blank?
71
82
  next unless valid_field?(field)
72
83
 
73
- query = "%#{search_value.strip}%"
84
+ query = like_query(search_value.strip)
74
85
 
75
- if field.include?("__")
86
+ if field.include?('__')
76
87
  @scope, condition =
77
88
  nested_condition(
78
89
  @scope,
@@ -81,17 +92,17 @@ module BharatFilterEngine
81
92
  )
82
93
 
83
94
  condition
84
- elsif field == "id"
95
+ elsif field == 'id'
85
96
  quoted_column =
86
97
  @scope.klass
87
98
  .connection
88
99
  .quote_column_name(field)
89
100
 
90
101
  Arel.sql(
91
- "#{@scope.klass.quoted_table_name}.#{quoted_column}::text"
92
- ).matches(query)
102
+ "CAST(#{@scope.klass.quoted_table_name}.#{quoted_column} AS TEXT)"
103
+ ).matches(query, LIKE_ESCAPE_CHAR)
93
104
  else
94
- @scope.klass.arel_table[field].matches(query)
105
+ @scope.klass.arel_table[field].matches(query, LIKE_ESCAPE_CHAR)
95
106
  end
96
107
  end
97
108
 
@@ -110,7 +121,7 @@ module BharatFilterEngine
110
121
  end
111
122
 
112
123
  def valid_field?(field)
113
- if field.include?("__")
124
+ if field.include?('__')
114
125
  !resolver.resolve_column(field).nil?
115
126
  else
116
127
  @scope.klass.column_names.include?(field)
@@ -130,12 +141,19 @@ module BharatFilterEngine
130
141
 
131
142
  condition =
132
143
  result[:klass]
133
- .arel_table[result[:column]]
134
- .matches(query)
144
+ .arel_table[result[:column]]
145
+ .matches(query, LIKE_ESCAPE_CHAR)
135
146
 
136
147
  [joined_scope, condition]
137
148
  end
138
149
 
150
+ def like_query(term)
151
+ escaped =
152
+ term.to_s.gsub(/[\\%_]/) { |char| "#{LIKE_ESCAPE_CHAR}#{char}" }
153
+
154
+ "%#{escaped}%"
155
+ end
156
+
139
157
  # AssociationResolver only needs @scope.klass (which never changes
140
158
  # across joins), so a single instance can be reused for the whole
141
159
  # search instead of being rebuilt every time the scope is updated.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BharatFilterEngine
4
- VERSION = "0.1.1"
5
- end
4
+ VERSION = '0.2.0pre'
5
+ end
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_record"
4
- require "active_model"
5
- require "active_support"
6
- require "json"
3
+ require 'active_record'
4
+ require 'active_model'
5
+ require 'active_support'
6
+ require 'json'
7
7
 
8
- require_relative "bharat_filter_engine/version"
9
- require_relative "bharat_filter_engine/errors"
10
- require_relative "bharat_filter_engine/association_resolver"
11
- require_relative "bharat_filter_engine/rule_normalizer"
12
- require_relative "bharat_filter_engine/search_builder"
13
- require_relative "bharat_filter_engine/dynamic_filter_values"
14
- require_relative "bharat_filter_engine/engine"
8
+ require_relative 'bharat_filter_engine/version'
9
+ require_relative 'bharat_filter_engine/errors'
10
+ require_relative 'bharat_filter_engine/association_resolver'
11
+ require_relative 'bharat_filter_engine/rule_normalizer'
12
+ require_relative 'bharat_filter_engine/search_builder'
13
+ require_relative 'bharat_filter_engine/dynamic_filter_values'
14
+ require_relative 'bharat_filter_engine/engine'
15
15
 
16
16
  module BharatFilterEngine
17
17
  class << self
@@ -31,4 +31,4 @@ module BharatFilterEngine
31
31
  )
32
32
  end
33
33
  end
34
- end
34
+ end
@@ -1,19 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
3
+ require 'spec_helper'
4
4
 
5
5
  RSpec.describe BharatFilterEngine::AssociationResolver do
6
-
7
6
  let(:resolver) do
8
7
  described_class.new(
9
8
  Sale.all
10
9
  )
11
10
  end
12
11
 
13
- describe "#resolve" do
14
-
15
- it "resolves a direct association" do
16
- result = resolver.resolve("lead")
12
+ describe '#resolve' do
13
+ it 'resolves a direct association' do
14
+ result = resolver.resolve('lead')
17
15
 
18
16
  expect(result[:associations]).to eq(
19
17
  [:lead]
@@ -24,13 +22,13 @@ RSpec.describe BharatFilterEngine::AssociationResolver do
24
22
  )
25
23
  end
26
24
 
27
- it "resolves nested associations" do
25
+ it 'resolves nested associations' do
28
26
  result = resolver.resolve(
29
- "lead__client"
27
+ 'lead__client'
30
28
  )
31
29
 
32
30
  expect(result[:associations]).to eq(
33
- [:lead, :client]
31
+ %i[lead client]
34
32
  )
35
33
 
36
34
  expect(result[:klass]).to eq(
@@ -38,16 +36,16 @@ RSpec.describe BharatFilterEngine::AssociationResolver do
38
36
  )
39
37
  end
40
38
 
41
- it "resolves multi-level associations" do
39
+ it 'resolves multi-level associations' do
42
40
  result = resolver.resolve(
43
- "lead__client__organization"
41
+ 'lead__client__organization'
44
42
  )
45
43
 
46
44
  expect(result[:associations]).to eq(
47
- [
48
- :lead,
49
- :client,
50
- :organization
45
+ %i[
46
+ lead
47
+ client
48
+ organization
51
49
  ]
52
50
  )
53
51
 
@@ -56,38 +54,51 @@ RSpec.describe BharatFilterEngine::AssociationResolver do
56
54
  )
57
55
  end
58
56
 
59
- it "returns nil for invalid association" do
57
+ it 'returns nil for invalid association' do
60
58
  result = resolver.resolve(
61
- "unknown"
59
+ 'unknown'
62
60
  )
63
61
 
64
62
  expect(result).to be_nil
65
63
  end
66
- end
67
64
 
68
- describe "#resolve_column" do
65
+ it 'returns nil when an intermediate association in a multi-level path is invalid' do
66
+ result = resolver.resolve(
67
+ 'lead__not_a_real_association__name'
68
+ )
69
69
 
70
- it "resolves direct columns" do
70
+ expect(result).to be_nil
71
+ end
72
+
73
+ it 'returns nil for a blank path' do
74
+ result = resolver.resolve('')
75
+
76
+ expect(result).to be_nil
77
+ end
78
+ end
79
+
80
+ describe '#resolve_column' do
81
+ it 'resolves direct columns' do
71
82
  result =
72
83
  resolver.resolve_column(
73
- "stage"
84
+ 'stage'
74
85
  )
75
86
 
76
87
  expect(result[:column]).to eq(
77
- "stage"
88
+ 'stage'
78
89
  )
79
90
 
80
91
  expect(result[:associations]).to eq([])
81
92
  end
82
93
 
83
- it "resolves nested columns" do
94
+ it 'resolves nested columns' do
84
95
  result =
85
96
  resolver.resolve_column(
86
- "lead__source"
97
+ 'lead__source'
87
98
  )
88
99
 
89
100
  expect(result[:column]).to eq(
90
- "source"
101
+ 'source'
91
102
  )
92
103
 
93
104
  expect(result[:associations]).to eq(
@@ -95,18 +106,18 @@ RSpec.describe BharatFilterEngine::AssociationResolver do
95
106
  )
96
107
  end
97
108
 
98
- it "resolves multi-level columns" do
109
+ it 'resolves multi-level columns' do
99
110
  result =
100
111
  resolver.resolve_column(
101
- "lead__client__name"
112
+ 'lead__client__name'
102
113
  )
103
114
 
104
115
  expect(result[:column]).to eq(
105
- "name"
116
+ 'name'
106
117
  )
107
118
 
108
119
  expect(result[:associations]).to eq(
109
- [:lead, :client]
120
+ %i[lead client]
110
121
  )
111
122
 
112
123
  expect(result[:klass]).to eq(
@@ -114,19 +125,27 @@ RSpec.describe BharatFilterEngine::AssociationResolver do
114
125
  )
115
126
  end
116
127
 
117
- it "returns nil for invalid column" do
128
+ it 'returns nil for invalid column' do
118
129
  result =
119
130
  resolver.resolve_column(
120
- "lead__invalid"
131
+ 'lead__invalid'
121
132
  )
122
133
 
123
134
  expect(result).to be_nil
124
135
  end
125
- end
126
136
 
127
- describe "#join" do
137
+ it 'returns nil when an intermediate association is invalid' do
138
+ result =
139
+ resolver.resolve_column(
140
+ 'lead__not_a_real_association__name'
141
+ )
142
+
143
+ expect(result).to be_nil
144
+ end
145
+ end
128
146
 
129
- it "joins one association" do
147
+ describe '#join' do
148
+ it 'joins one association' do
130
149
  scope =
131
150
  resolver.join(
132
151
  Sale.all,
@@ -136,25 +155,25 @@ RSpec.describe BharatFilterEngine::AssociationResolver do
136
155
  expect(
137
156
  scope.to_sql
138
157
  ).to include(
139
- "JOIN"
158
+ 'JOIN'
140
159
  )
141
160
  end
142
161
 
143
- it "joins nested associations" do
162
+ it 'joins nested associations' do
144
163
  scope =
145
164
  resolver.join(
146
165
  Sale.all,
147
- [
148
- :lead,
149
- :client
166
+ %i[
167
+ lead
168
+ client
150
169
  ]
151
170
  )
152
171
 
153
172
  expect(
154
173
  scope.to_sql
155
174
  ).to include(
156
- "JOIN"
175
+ 'JOIN'
157
176
  )
158
177
  end
159
178
  end
160
- end
179
+ end