From 8dc46b55f96defbaa08ae98b77eebb855fee5ada Mon Sep 17 00:00:00 2001 From: Filippos Vasilakis Date: Sun, 12 Feb 2017 20:54:37 +0100 Subject: [PATCH] Add not feature with tests --- README.md | 13 +++ lib/active_hash_relation/column_filters.rb | 114 +++++++++++++++++---- lib/active_hash_relation/filter_applier.rb | 17 ++- lib/active_hash_relation/version.rb | 2 +- spec/tests/not_filter_spec.rb | 105 +++++++++++++++++++ 5 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 spec/tests/not_filter_spec.rb diff --git a/README.md b/README.md index e5b694f..c140d55 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,19 @@ You can apply an `OR` on associations as well or even nested ones, there isn't m I suggest you though to take a look on the [tests](spec/tests/or_filter_spec.rb), cause the syntax gets a bit complex after a while ;) +### NOT Filter +You can apply an SQL `NOT` (for ActiveRecord 4+) using the following syntax: +`{not: {name: 'Filippos', email: {ends_with: '@gmail.com'}}}` + +It will generate: `WHERE (NOT (users.name = 'Filippos')) AND (NOT (users.email LIKE '%@gmail.com'))` + +You can apply an `NOT` on associations as well or even nested ones, there isn't much limitation on that. +I suggest you to also take a look on the [tests](spec/tests/not_filter_spec.rb). + +Also I should note that you need to add specific (partial) queries if you don't want +to have performance issues on tables with millions of rows. + + ### Scopes **Filtering on scopes is not enabled by default**. diff --git a/lib/active_hash_relation/column_filters.rb b/lib/active_hash_relation/column_filters.rb index b6ccfa9..107f44f 100644 --- a/lib/active_hash_relation/column_filters.rb +++ b/lib/active_hash_relation/column_filters.rb @@ -2,7 +2,11 @@ module ActiveHashRelation::ColumnFilters def filter_integer(resource, column, table_name, param) if param.is_a? Array n_param = param.to_s.gsub("\"","'").gsub("[","").gsub("]","") #fix this! - return resource.where("#{table_name}.#{column} IN (#{n_param})") + if @is_not + return resource.where.not("#{table_name}.#{column} IN (#{n_param})") + else + return resource.where("#{table_name}.#{column} IN (#{n_param})") + end elsif param.is_a? Hash if !param[:null].nil? return null_filters(resource, table_name, column, param) @@ -10,7 +14,11 @@ def filter_integer(resource, column, table_name, param) return apply_leq_geq_le_ge_filters(resource, table_name, column, param) end else - return resource.where("#{table_name}.#{column} = ?", param) + if @is_not + return resource.where.not("#{table_name}.#{column} = ?", param) + else + return resource.where("#{table_name}.#{column} = ?", param) + end end end @@ -25,7 +33,11 @@ def filter_decimal(resource, column, table_name, param) def filter_string(resource, column, table_name, param) if param.is_a? Array n_param = param.to_s.gsub("\"","'").gsub("[","").gsub("]","") #fix this! - return resource.where("#{table_name}.#{column} IN (#{n_param})") + if @is_not + return resource.where.not("#{table_name}.#{column} IN (#{n_param})") + else + return resource.where("#{table_name}.#{column} IN (#{n_param})") + end elsif param.is_a? Hash if !param[:null].nil? return null_filters(resource, table_name, column, param) @@ -33,7 +45,11 @@ def filter_string(resource, column, table_name, param) return apply_like_filters(resource, table_name, column, param) end else - return resource.where("#{table_name}.#{column} = ?", param) + if @is_not + return resource.where.not("#{table_name}.#{column} = ?", param) + else + return resource.where("#{table_name}.#{column} = ?", param) + end end end @@ -44,7 +60,11 @@ def filter_text(resource, column, param) def filter_date(resource, column, table_name, param) if param.is_a? Array n_param = param.to_s.gsub("\"","'").gsub("[","").gsub("]","") #fix this! - return resource.where("#{table_name}.#{column} IN (#{n_param})") + if @is_not + return resource.where.not("#{table_name}.#{column} IN (#{n_param})") + else + return resource.where("#{table_name}.#{column} IN (#{n_param})") + end elsif param.is_a? Hash if !param[:null].nil? return null_filters(resource, table_name, column, param) @@ -52,7 +72,11 @@ def filter_date(resource, column, table_name, param) return apply_leq_geq_le_ge_filters(resource, table_name, column, param) end else - resource = resource.where(column => param) + if @is_not + resource = resource.where.not(column => param) + else + resource = resource.where(column => param) + end end return resource @@ -61,7 +85,11 @@ def filter_date(resource, column, table_name, param) def filter_datetime(resource, column, table_name, param) if param.is_a? Array n_param = param.to_s.gsub("\"","'").gsub("[","").gsub("]","") #fix this! - return resource = resource.where("#{table_name}.#{column} IN (#{n_param})") + if @is_not + return resource = resource.where.not("#{table_name}.#{column} IN (#{n_param})") + else + return resource = resource.where("#{table_name}.#{column} IN (#{n_param})") + end elsif param.is_a? Hash if !param[:null].nil? return null_filters(resource, table_name, column, param) @@ -69,7 +97,11 @@ def filter_datetime(resource, column, table_name, param) return apply_leq_geq_le_ge_filters(resource, table_name, column, param) end else - resource = resource.where(column => param) + if @is_not + resource = resource.where.not(column => param) + else + resource = resource.where(column => param) + end end return resource @@ -85,7 +117,11 @@ def filter_boolean(resource, column, table_name, param) b_param = ActiveRecord::Type::Boolean.new.type_cast_from_database(param) end - resource = resource.where(column => b_param) + if @is_not + resource = resource.where.not(column => b_param) + else + resource = resource.where(column => b_param) + end end end @@ -95,15 +131,31 @@ def apply_leq_geq_le_ge_filters(resource, table_name, column, param) return resource.where("#{table_name}.#{column} = ?", param[:eq]) if param[:eq] if !param[:leq].blank? - resource = resource.where("#{table_name}.#{column} <= ?", param[:leq]) + if @is_not + resource = resource.where.not("#{table_name}.#{column} <= ?", param[:leq]) + else + resource = resource.where("#{table_name}.#{column} <= ?", param[:leq]) + end elsif !param[:le].blank? - resource = resource.where("#{table_name}.#{column} < ?", param[:le]) + if @is_not + resource = resource.where.not("#{table_name}.#{column} < ?", param[:le]) + else + resource = resource.where("#{table_name}.#{column} < ?", param[:le]) + end end if !param[:geq].blank? - resource = resource.where("#{table_name}.#{column} >= ?", param[:geq]) + if @is_not + resource = resource.where.not("#{table_name}.#{column} >= ?", param[:geq]) + else + resource = resource.where("#{table_name}.#{column} >= ?", param[:geq]) + end elsif !param[:ge].blank? - resource = resource.where("#{table_name}.#{column} > ?", param[:ge]) + if @is_not + resource = resource.where.not("#{table_name}.#{column} > ?", param[:ge]) + else + resource = resource.where("#{table_name}.#{column} > ?", param[:ge]) + end end return resource @@ -114,19 +166,35 @@ def apply_like_filters(resource, table_name, column, param) like_method = "ILIKE" if param[:with_ilike] if !param[:starts_with].blank? - resource = resource.where("#{table_name}.#{column} #{like_method} ?", "#{param[:starts_with]}%") + if @is_not + resource = resource.where.not("#{table_name}.#{column} #{like_method} ?", "#{param[:starts_with]}%") + else + resource = resource.where("#{table_name}.#{column} #{like_method} ?", "#{param[:starts_with]}%") + end end if !param[:ends_with].blank? - resource = resource.where("#{table_name}.#{column} #{like_method} ?", "%#{param[:ends_with]}") + if @is_not + resource = resource.where.not("#{table_name}.#{column} #{like_method} ?", "%#{param[:ends_with]}") + else + resource = resource.where("#{table_name}.#{column} #{like_method} ?", "%#{param[:ends_with]}") + end end if !param[:like].blank? - resource = resource.where("#{table_name}.#{column} #{like_method} ?", "%#{param[:like]}%") + if @is_not + resource = resource.where.not("#{table_name}.#{column} #{like_method} ?", "%#{param[:like]}%") + else + resource = resource.where("#{table_name}.#{column} #{like_method} ?", "%#{param[:like]}%") + end end if !param[:eq].blank? - resource = resource.where("#{table_name}.#{column} = ?", param[:eq]) + if @is_not + resource = resource.where.not("#{table_name}.#{column} = ?", param[:eq]) + else + resource = resource.where("#{table_name}.#{column} = ?", param[:eq]) + end end return resource @@ -134,11 +202,19 @@ def apply_like_filters(resource, table_name, column, param) def null_filters(resource, table_name, column, param) if param[:null] == true - resource = resource.where("#{table_name}.#{column} IS NULL") + if @is_not + resource = resource.where.not("#{table_name}.#{column} IS NULL") + else + resource = resource.where("#{table_name}.#{column} IS NULL") + end end if param[:null] == false - resource = resource.where("#{table_name}.#{column} IS NOT NULL") + if @is_not + resource = resource.where.not("#{table_name}.#{column} IS NOT NULL") + else + resource = resource.where("#{table_name}.#{column} IS NOT NULL") + end end return resource diff --git a/lib/active_hash_relation/filter_applier.rb b/lib/active_hash_relation/filter_applier.rb index 86d7e96..bfdb622 100644 --- a/lib/active_hash_relation/filter_applier.rb +++ b/lib/active_hash_relation/filter_applier.rb @@ -9,7 +9,7 @@ class FilterApplier attr_reader :configuration - def initialize(resource, params, include_associations: false, model: nil) + def initialize(resource, params, include_associations: false, model: nil, is_not: false) @configuration = Module.nesting.last.configuration @resource = resource if params.respond_to?(:to_unsafe_h) @@ -19,10 +19,12 @@ def initialize(resource, params, include_associations: false, model: nil) end @include_associations = include_associations @model = find_model(model) + is_not ? @is_not = true : @is_not = false end def apply_filters run_or_filters + run_not_filters table_name = @model.table_name @model.columns.each do |c| @@ -68,7 +70,7 @@ def filter_class(resource_name) def run_or_filters if @params[:or].is_a?(Array) if ActiveRecord::VERSION::MAJOR < 5 - return Rails.logger.warn("OR query is supported on ActiveRecord 5+") + return Rails.logger.warn("OR query is supported on ActiveRecord 5+") end if @params[:or].length >= 2 @@ -83,5 +85,16 @@ def run_or_filters end end end + + def run_not_filters + if @params[:not].is_a?(Hash) && !@params[:not].blank? + @resource = self.class.new( + @resource, + @params[:not], + include_associations: @include_associations, + is_not: true + ).apply_filters + end + end end end diff --git a/lib/active_hash_relation/version.rb b/lib/active_hash_relation/version.rb index 201293a..6c2a072 100644 --- a/lib/active_hash_relation/version.rb +++ b/lib/active_hash_relation/version.rb @@ -1,3 +1,3 @@ module ActiveHashRelation - VERSION = "1.2.0" + VERSION = "1.3.0" end diff --git a/spec/tests/not_filter_spec.rb b/spec/tests/not_filter_spec.rb new file mode 100644 index 0000000..74da93f --- /dev/null +++ b/spec/tests/not_filter_spec.rb @@ -0,0 +1,105 @@ +describe ActiveHashRelation do + include Helpers + + context 'NOT filter' do + it "one NOT clause" do + hash = {not: {name: 'Filippos'}} + + query = HelperClass.new.apply_filters(User.all, hash).to_sql + expected_query = q( + "SELECT users.* FROM users WHERE (NOT (users.name = 'Filippos'))" + ) + + expect(strip(query)).to eq expected_query.to_s + end + + it "multiple NOT clauses" do + hash = {not: {name: 'Filippos', email: 'vasilakisfil@gmail.com'}} + + query = HelperClass.new.apply_filters(User.all, hash).to_sql + expected_query = q( + "SELECT users.* FROM users WHERE", + "(NOT (users.name = 'Filippos'))", + "AND", + "(NOT (users.email = 'vasilakisfil@gmail.com'))" + ) + + expect(strip(query)).to eq expected_query.to_s + end + + if ActiveRecord::VERSION::MAJOR >= 5 + it "NOT clause inside OR clause" do + hash = {or: [{not: {name: 'Filippos', token: '123'}}, {not: {name: 'Vasilis'}}]} + + query = HelperClass.new.apply_filters(User.all, hash).to_sql + expected_query = q( + "SELECT users.* FROM users", + "WHERE", + "(", + "(NOT (users.name = 'Filippos')) AND (NOT (users.token = '123'))", + "OR", + "(NOT (users.name = 'Vasilis'))", + ")" + ) + + expect(strip(query)).to eq expected_query.to_s + end + end + + it "complex NOT clause" do + hash = {not: {name: 'Filippos', email: {ends_with: '@gmail.com'}}} + + query = HelperClass.new.apply_filters(User.all, hash).to_sql + expected_query = q( + "SELECT users.* FROM users", + "WHERE", + "(NOT (users.name = 'Filippos'))", + "AND", + "(NOT (users.email LIKE '%@gmail.com'))", + ) + + expect(strip(query)).to eq expected_query.to_s + end + + context "on NULL" do + it "NOT clause on null" do + hash = {not: {name: {null: true}}} + + query = HelperClass.new.apply_filters(User.all, hash).to_sql + expected_query = q( + "SELECT users.* FROM users WHERE (NOT (users.name IS NULL))" + ) + + expect(strip(query)).to eq expected_query.to_s + end + + it "NOT clause on not null" do + hash = {not: {name: {null: false}}} + + query = HelperClass.new.apply_filters(User.all, hash).to_sql + expected_query = q( + "SELECT users.* FROM users WHERE (NOT (users.name IS NOT NULL))" + ) + + expect(strip(query)).to eq expected_query.to_s + end + end + + it "NOT clause on associations" do + hash = {microposts: {not: {content: 'Sveavägen 4', id: 1}}} + + query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql + expected_query = q( + "SELECT users.* FROM users", + "INNER JOIN microposts ON microposts.user_id = users.id", + "WHERE", + "(NOT (microposts.id = 1))", + "AND", + "(NOT (microposts.content = 'Sveavägen 4'))" + ) + + expect(strip(query)).to eq expected_query.to_s + end + + end +end