bharat_filter_engine 0.1.2 → 0.2.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.
@@ -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,22 @@
2
2
 
3
3
  module BharatFilterEngine
4
4
  class SearchBuilder
5
- def initialize(scope:, allowed_columns:)
5
+ LIKE_ESCAPE_CHAR = '\\'
6
+
7
+ def initialize(scope:, allowed_columns:, tracker: nil)
6
8
  @scope = scope
9
+ @tracker = tracker || JoinTracker.new(scope.klass)
7
10
  @allowed_columns =
8
11
  Array(allowed_columns).map(&:to_s)
12
+
13
+ if @allowed_columns.present? &&
14
+ @allowed_columns.none? { |field| valid_field?(field) }
15
+
16
+ raise InvalidConfigurationError,
17
+ 'BharatFilterEngine: none of the configured allowed_columns ' \
18
+ "#{@allowed_columns.inspect} could be resolved on " \
19
+ "#{@scope.klass}. Check `dbcolumns:` in your filter config."
20
+ end
9
21
  end
10
22
 
11
23
  def apply(value)
@@ -13,7 +25,7 @@ module BharatFilterEngine
13
25
 
14
26
  search = value.to_s.strip
15
27
 
16
- if search.include?("=")
28
+ if search.include?('=')
17
29
  field_based_search(search)
18
30
  else
19
31
  simple_search(search)
@@ -23,13 +35,13 @@ module BharatFilterEngine
23
35
  private
24
36
 
25
37
  def simple_search(search)
26
- query = "%#{search}%"
38
+ query = like_query(search)
27
39
  conditions = []
28
40
 
29
41
  @allowed_columns.each do |dbcolumn|
30
42
  next unless valid_field?(dbcolumn)
31
43
 
32
- if dbcolumn.include?("__")
44
+ if dbcolumn.include?('__')
33
45
  @scope, condition =
34
46
  nested_condition(
35
47
  @scope,
@@ -41,7 +53,7 @@ module BharatFilterEngine
41
53
  else
42
54
  table = @scope.klass.arel_table
43
55
 
44
- conditions << table[dbcolumn].matches(query)
56
+ conditions << table[dbcolumn].matches(query, LIKE_ESCAPE_CHAR)
45
57
  end
46
58
  end
47
59
 
@@ -60,19 +72,20 @@ module BharatFilterEngine
60
72
  # is skipped and unrelated records leak through unfiltered.
61
73
  def field_based_search(search)
62
74
  and_conditions =
63
- search.split("&").map do |and_group|
75
+ search.split('&').map do |and_group|
64
76
  or_conditions =
65
- and_group.split("|").filter_map do |expression|
77
+ and_group.split('|').filter_map do |expression|
66
78
  field, search_value =
67
- expression.split("=", 2)
79
+ expression.split('=', 2)
68
80
 
69
81
  next if field.blank?
70
82
  next if search_value.blank?
83
+ next unless @allowed_columns.include?(field)
71
84
  next unless valid_field?(field)
72
85
 
73
- query = "%#{search_value.strip}%"
86
+ query = like_query(search_value.strip)
74
87
 
75
- if field.include?("__")
88
+ if field.include?('__')
76
89
  @scope, condition =
77
90
  nested_condition(
78
91
  @scope,
@@ -81,17 +94,17 @@ module BharatFilterEngine
81
94
  )
82
95
 
83
96
  condition
84
- elsif field == "id"
97
+ elsif field == 'id'
85
98
  quoted_column =
86
99
  @scope.klass
87
100
  .connection
88
101
  .quote_column_name(field)
89
102
 
90
103
  Arel.sql(
91
- "#{@scope.klass.quoted_table_name}.#{quoted_column}::text"
92
- ).matches(query)
104
+ "CAST(#{@scope.klass.quoted_table_name}.#{quoted_column} AS TEXT)"
105
+ ).matches(query, LIKE_ESCAPE_CHAR)
93
106
  else
94
- @scope.klass.arel_table[field].matches(query)
107
+ @scope.klass.arel_table[field].matches(query, LIKE_ESCAPE_CHAR)
95
108
  end
96
109
  end
97
110
 
@@ -110,7 +123,7 @@ module BharatFilterEngine
110
123
  end
111
124
 
112
125
  def valid_field?(field)
113
- if field.include?("__")
126
+ if field.include?('__')
114
127
  !resolver.resolve_column(field).nil?
115
128
  else
116
129
  @scope.klass.column_names.include?(field)
@@ -121,21 +134,32 @@ module BharatFilterEngine
121
134
  result = resolver.resolve_column(field)
122
135
 
123
136
  return [scope, nil] unless result
137
+ return [scope, subquery_condition(result, query)] if @tracker.claim(result) == :subquery
124
138
 
125
- joined_scope =
126
- resolver.join(
127
- scope,
128
- result[:associations]
129
- )
139
+ joined_scope = resolver.join(scope, result[:associations]).distinct
130
140
 
131
- condition =
132
- result[:klass]
133
- .arel_table[result[:column]]
134
- .matches(query)
141
+ condition = result[:klass].arel_table[result[:column]].matches(query, LIKE_ESCAPE_CHAR)
135
142
 
136
143
  [joined_scope, condition]
137
144
  end
138
145
 
146
+ # `primary_key IN (SELECT ... JOIN ... WHERE column LIKE ...)` for a nested column whose
147
+ # table is already joined through another path (e.g. client + referrer -> clients).
148
+ def subquery_condition(result, query)
149
+ klass = @scope.klass
150
+ matcher = result[:klass].arel_table[result[:column]].matches(query, LIKE_ESCAPE_CHAR)
151
+ subquery = resolver.subquery(result[:associations]) { |relation| relation.where(matcher) }
152
+
153
+ klass.arel_table[klass.primary_key].in(subquery.arel)
154
+ end
155
+
156
+ def like_query(term)
157
+ escaped =
158
+ term.to_s.gsub(/[\\%_]/) { |char| "#{LIKE_ESCAPE_CHAR}#{char}" }
159
+
160
+ "%#{escaped}%"
161
+ end
162
+
139
163
  # AssociationResolver only needs @scope.klass (which never changes
140
164
  # across joins), so a single instance can be reused for the whole
141
165
  # 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.2"
4
+ VERSION = '0.2.0'
5
5
  end
@@ -1,17 +1,19 @@
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/column_helper'
12
+ require_relative 'bharat_filter_engine/rule_normalizer'
13
+ require_relative 'bharat_filter_engine/search_builder'
14
+ require_relative 'bharat_filter_engine/dynamic_filter_values'
15
+ require_relative 'bharat_filter_engine/join_tracker'
16
+ require_relative 'bharat_filter_engine/engine'
15
17
 
16
18
  module BharatFilterEngine
17
19
  class << self
@@ -31,4 +33,4 @@ module BharatFilterEngine
31
33
  )
32
34
  end
33
35
  end
34
- end
36
+ 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