diff --git a/README.md b/README.md index a5bb809..e5b694f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ apply_filters(resource, {updated_at: { geq: "2014-11-2 14:25:04"}, unit: {id: 9} ``` or even filter a resource based on it's associations' associations: ```ruby -apply_filters(resource, {updated_at: { geq: "2014-11-2 14:25:04"}, unit: {id: 9, areas: {id: 22} }}) +apply_filters(resource, {updated_at: { geq: "2014-11-2 14:25:04"}, unit: {id: 9, areas: {or: [{id: 22}, {id: 21}]} }}) ``` and the list could go on.. Basically your whole db is exposed\* there. It's perfect for filtering a collection of resources on APIs. @@ -30,7 +30,7 @@ which might or might not be a security issue. If you don't like that check Add this line to your application's Gemfile: - gem 'active_hash_relation' + gem 'active_hash_relation', '~> 1.2.0 And then execute: @@ -39,8 +39,10 @@ And then execute: Or install it yourself as: $ gem install active_hash_relation + ## How to use -The gem exposes only one method: `apply_filters(resource, hash_params, include_associations: true, model: nil)`. `resource` is expected to be an ActiveRecord::Relation. +The gem exposes only one method: `apply_filters(resource, hash_params, include_associations: true, model: nil)`. +`resource` is expected to be an ActiveRecord::Relation. That way, you can add your custom filters before passing the `Relation` to `ActiveHashRelation`. In order to use it you have to include ActiveHashRelation module in your class. For instance in a Rails API controller you would do: @@ -78,17 +80,21 @@ end ActiveHashRelation.initialize! ``` - ## The API ### Columns -For each param, `apply_filters` method will search in the model's (derived from the first param, or explicitly defined as the last param) all the record's column names and associations. (filtering based on scopes are not working at the moment but will be supported soon). For each column, if there is such a param, it will apply the filter based on the column type. The following column types are supported: +For each param, `apply_filters` method will search in the model's (derived from the +first param, or explicitly defined as the last param) all the record's column names +and associations. (filtering based on scopes are not working at the moment but +will be supported soon). For each column, if there is such a param, it will +apply the filter based on the column type. The following column types are supported: + #### Integer, Float, Decimal, Date, Time or Datetime/Timestamp You can apply an equality filter: * `{example_column: 500}` -or using an array +or using an array (`ActiveRecord` translates that internally to an `IN` query) * `{example_column: [500, 40]}` or using a hash as a value you get more options: @@ -157,6 +163,17 @@ You can apply null filter for generate query like this `"users.name IS NULL"` or this can be used also for relations tables, so you can write like this `{ books: {title: {null: false }} }` + +### OR Filter +You can apply an SQL `OR` (for ActiveRecord 5+) using the following syntax: +`{or: [{name: 'Filippos'}, {name: 'Vasilis'}]}` + +It will generate: `WHERE ((users.name = 'Filippos') OR (users.name = 'Vasilis'))` + +You can apply an `OR` on associations as well or even nested ones, there isn't much limitation on that. +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 ;) + + ### 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 981eb6f..b6ccfa9 100644 --- a/lib/active_hash_relation/column_filters.rb +++ b/lib/active_hash_relation/column_filters.rb @@ -1,8 +1,4 @@ module ActiveHashRelation::ColumnFilters - def filter_primary(resource, column, param) - resource = resource.where(id: param) - end - def filter_integer(resource, column, table_name, param) if param.is_a? Array n_param = param.to_s.gsub("\"","'").gsub("[","").gsub("]","") #fix this! diff --git a/lib/active_hash_relation/filter_applier.rb b/lib/active_hash_relation/filter_applier.rb index 503c842..86d7e96 100644 --- a/lib/active_hash_relation/filter_applier.rb +++ b/lib/active_hash_relation/filter_applier.rb @@ -18,34 +18,17 @@ def initialize(resource, params, include_associations: false, model: nil) @params = HashWithIndifferentAccess.new(params) end @include_associations = include_associations - @model = model + @model = find_model(model) end - def apply_filters - unless @model - @model = model_class_name(@resource) - if @model.nil? || engine_name == @model.to_s - @model = model_class_name(@resource, true) - end - end + run_or_filters + table_name = @model.table_name @model.columns.each do |c| next if @params[c.name.to_s].nil? next if @params[c.name.to_s].is_a?(String) && @params[c.name.to_s].blank? - if c.respond_to?(:primary) - if c.primary - @resource = filter_primary(@resource, c.name, @params[c.name]) - next - end - else #rails 4.2 - if @model.primary_key == c.name - @resource = filter_primary(@resource, c.name, @params[c.name]) - next - end - end - case c.type when :integer @resource = filter_integer(@resource, c.name, table_name, @params[c.name]) @@ -81,5 +64,24 @@ def apply_filters def filter_class(resource_name) "#{configuration.filter_class_prefix}#{resource_name.pluralize}#{configuration.filter_class_suffix}".constantize end + + 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+") + end + + if @params[:or].length >= 2 + array = @params[:or].map do |or_param| + self.class.new(@resource, or_param, include_associations: @include_associations).apply_filters + end + + @resource = @resource.merge(array[0]) + array[1..-1].each{|query| @resource = @resource.or(query)} + else + Rails.logger.warn("Can't run an OR with 1 element!") + end + end + end end end diff --git a/lib/active_hash_relation/helpers.rb b/lib/active_hash_relation/helpers.rb index 34847a3..fe28c19 100644 --- a/lib/active_hash_relation/helpers.rb +++ b/lib/active_hash_relation/helpers.rb @@ -12,5 +12,16 @@ def model_class_name(resource, _engine = false) def engine_name Rails::Engine.subclasses[0].to_s.split('::').first end + + def find_model(model) + return model if model + + model = model_class_name(@resource) + if model.nil? || engine_name == model.to_s + model = model_class_name(@resource, true) + end + + return model + end end end diff --git a/lib/active_hash_relation/version.rb b/lib/active_hash_relation/version.rb index 70b7a11..201293a 100644 --- a/lib/active_hash_relation/version.rb +++ b/lib/active_hash_relation/version.rb @@ -1,3 +1,3 @@ module ActiveHashRelation - VERSION = "1.1.0" + VERSION = "1.2.0" end diff --git a/spec/support/support.rb b/spec/support/support.rb index 0000daf..f4a2064 100644 --- a/spec/support/support.rb +++ b/spec/support/support.rb @@ -43,7 +43,15 @@ def query(str) end def q(*args) - args.join(' ') + args.each.with_index.inject(''){|memo, (str, index)| + if args[index-1] == '(' + "#{memo}#{str}" + elsif str == ')' + "#{memo}#{str}" + else + "#{memo} #{str}" + end + }.strip end end diff --git a/spec/tests/or_filter_spec.rb b/spec/tests/or_filter_spec.rb new file mode 100644 index 0000000..34fbcb7 --- /dev/null +++ b/spec/tests/or_filter_spec.rb @@ -0,0 +1,98 @@ +describe ActiveHashRelation do + include Helpers + + if ActiveRecord::VERSION::MAJOR < 5 + context 'OR filter' do + it "one OR clause" do + logger = double('logger') + allow(logger).to receive(:warn) + allow(Rails).to receive(:logger).and_return(logger) + + hash = {or: [{name: 'Filippos'}, {name: 'Vasilis'}]} + + HelperClass.new.apply_filters(User.all, hash).to_sql + expect(logger).to have_received(:warn) + end + end + + else + context 'OR filter' do + it "one OR clause" do + hash = {or: [{name: 'Filippos'}, {name: 'Vasilis'}]} + + query = HelperClass.new.apply_filters(User.all, hash).to_sql + expected_query = q( + "SELECT users.* FROM users", + "WHERE ((users.name = 'Filippos') OR (users.name = 'Vasilis'))" + ) + + expect(strip(query)).to eq expected_query.to_s + end + + it "multiple OR clauses" do + hash = {or: [{or: [{name: 'Filippos'}, {name: 'Vasilis'}]}, {or: [{id: 1}, {id: 2}]}]} + + query = HelperClass.new.apply_filters(User.all, hash).to_sql + expected_query = q( + "SELECT users.* FROM users", + "WHERE", + "(", + "((users.name = 'Filippos') OR (users.name = 'Vasilis'))", + "OR", + "((users.id = 1) OR (users.id = 2))", + ")" + ) + + expect(strip(query)).to eq expected_query.to_s + end + + it "one complex OR clause" do + hash = {or: [{name: 'Filippos', token: '123'}, {name: 'Vasilis'}]} + + query = HelperClass.new.apply_filters(User.all, hash).to_sql + expected_query = q( + "SELECT users.* FROM users", + "WHERE", + "(", + "(users.name = 'Filippos') AND (users.token = '123')", + "OR", + "(users.name = 'Vasilis')", + ")" + ) + + expect(strip(query)).to eq expected_query.to_s + end + + it "nested OR clause" do + hash = {or: [{or: [{name: 'Filippos'}, {token: '123'}]}, {name: 'Vasilis'}]} + + query = HelperClass.new.apply_filters(User.all, hash).to_sql + expected_query = q( + "SELECT users.* FROM users", + "WHERE", + "(", + "((users.name = 'Filippos') OR (users.token = '123'))", + "OR", + "(users.name = 'Vasilis')", + ")" + ) + + expect(strip(query)).to eq expected_query.to_s + end + + it "OR clause on associations" do + hash = {microposts: {or: [{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 ((microposts.content = 'Sveavägen 4') OR (microposts.id = 1))" + ) + + expect(strip(query)).to eq expected_query.to_s + end + end + end +end + diff --git a/spec/tests/primary_key_spec.rb b/spec/tests/primary_key_spec.rb new file mode 100644 index 0000000..ac47633 --- /dev/null +++ b/spec/tests/primary_key_spec.rb @@ -0,0 +1,29 @@ +describe ActiveHashRelation do + include Helpers + + context 'primary_key' do + it "one key" do + hash = {id: 1} + + query = HelperClass.new.apply_filters(User.all, hash).to_sql + expected_query = q( + "SELECT users.* FROM users WHERE (users.id = 1)" + ) + + expect(strip(query)).to eq expected_query.to_s + end + + it "multiple keys" do + hash = {id: [1,2,3,4]} + + query = HelperClass.new.apply_filters(User.all, hash).to_sql + expected_query = q( + "SELECT users.* FROM users WHERE (users.id IN (1, 2, 3, 4))" + ) + + expect(strip(query)).to eq expected_query.to_s + end + end +end + +