diff --git a/docs/docs/going-further/merging-searches.md b/docs/docs/going-further/merging-searches.md index b20608ca6..f8112ca29 100644 --- a/docs/docs/going-further/merging-searches.md +++ b/docs/docs/going-further/merging-searches.md @@ -3,6 +3,48 @@ sidebar_position: 5 title: Merging searches --- +## Chainable Search Methods (New!) + +The easiest way to combine searches is using the new chainable `and` and `or` methods: + +```ruby +# Simple OR operation +search_parents = Person.ransack(parent_name_eq: "A") +search_children = Person.ransack(children_name_eq: "B") + +combined_search = search_parents.or(search_children) +results = combined_search.result + +# Simple AND operation +search_name = Person.ransack(name_cont: "John") +search_email = Person.ransack(email_cont: "example.com") + +combined_search = search_name.and(search_email) +results = combined_search.result + +# Chained operations +search1 = Person.ransack(name_eq: "Alice") +search2 = Person.ransack(name_eq: "Bob") +search3 = Person.ransack(name_eq: "Charlie") + +# Find records matching any of the three names +combined_search = search1.or(search2).or(search3) +results = combined_search.result + +# Mixed AND/OR operations +search_johns = Person.ransack(name_eq: "John") +search_admins = Person.ransack(role_eq: "admin") +search_managers = Person.ransack(role_eq: "manager") + +# Find Johns who are admins, OR anyone who is a manager +combined_search = search_johns.and(search_admins).or(search_managers) +results = combined_search.result +``` + +This approach automatically handles context sharing and join management, making it much simpler than the manual approach below. + +## Manual Approach (Advanced) + To find records that match multiple searches, it's possible to merge all the ransack search conditions into an ActiveRecord relation to perform a single query. In order to avoid conflicts between joined table names it's necessary to set up a shared context to track table aliases used across all the conditions before initializing the searches: ```ruby @@ -38,4 +80,4 @@ WHERE ( ORDER BY "people"."id" DESC ``` -Admittedly this is not as simple as it should be, but it's workable for now. (Implementing [issue 417](https://github.com/activerecord-hackery/ransack/issues/417) could make this more straightforward.) +The manual approach gives you more control but requires more setup. For most use cases, the chainable methods provide a simpler and more intuitive API. diff --git a/lib/ransack/search.rb b/lib/ransack/search.rb index 03115c5ac..cd85f69ff 100644 --- a/lib/ransack/search.rb +++ b/lib/ransack/search.rb @@ -13,7 +13,7 @@ module Ransack class Search include Naming - attr_reader :base, :context + attr_reader :base, :context, :scope_args delegate :object, :klass, to: :context delegate :new_grouping, :new_condition, @@ -116,6 +116,14 @@ def method_missing(method_id, *args) end end + def and(other_search) + ChainedSearch.new(self, other_search, :and) + end + + def or(other_search) + ChainedSearch.new(self, other_search, :or) + end + def inspect details = [ [:class, klass.name], @@ -192,4 +200,141 @@ def collapse_multiparameter_attributes!(attrs) end end + + # ChainedSearch represents a combination of two searches with AND or OR logic + class ChainedSearch + include Naming + + attr_reader :left_search, :right_search, :combinator, :context + + delegate :object, :klass, to: :context + + def initialize(left_search, right_search, combinator = :and) + @left_search = left_search + @right_search = right_search + @combinator = combinator.to_s + + # Use the left search's context, but ensure both searches can use the same context + @context = ensure_shared_context(left_search, right_search) + end + + def result(opts = {}) + # Create a virtual grouping that represents the combination of both searches + combined_grouping = create_combined_grouping + + # Create a temporary search with the combined grouping + temp_search = create_temp_search_with_grouping(combined_grouping) + + # Use the context's evaluation method to properly handle joins and conditions + @context.evaluate(temp_search, opts) + end + + def and(other_search) + ChainedSearch.new(self, other_search, :and) + end + + def or(other_search) + ChainedSearch.new(self, other_search, :or) + end + + def inspect + "Ransack::ChainedSearch" + end + + # Delegate scope_args for compatibility + def scope_args + combined_args = {} + + if @left_search.respond_to?(:scope_args) + combined_args.merge!(@left_search.scope_args) + end + + if @right_search.respond_to?(:scope_args) + combined_args.merge!(@right_search.scope_args) + end + + combined_args + end + + private + + def create_combined_grouping + # Create a new grouping with the appropriate combinator + grouping = Nodes::Grouping.new(@context, @combinator) + + # Add the base groupings from both searches + left_base = get_base_from_search(@left_search) + right_base = get_base_from_search(@right_search) + + # Add the bases as child groupings + grouping.groupings << left_base if left_base + grouping.groupings << right_base if right_base + + grouping + end + + def create_temp_search_with_grouping(grouping) + # Create a minimal search object that can be used with context.evaluate + temp_search = Object.new + temp_search.define_singleton_method(:base) { grouping } + temp_search.define_singleton_method(:sorts) { [] } + temp_search + end + + def get_base_from_search(search) + if search.is_a?(ChainedSearch) + # For ChainedSearch, create its combined grouping + search.send(:create_combined_grouping) + else + # For regular Search, return its base grouping + search.base + end + end + + private + + def ensure_shared_context(left_search, right_search) + # If searches already share a context, use it + if left_search.context == right_search.context + return left_search.context + end + + # Create a new shared context for the same class + shared_context = Context.for(left_search.klass) + + # Re-create searches with shared context to ensure proper join handling + recreate_search_with_context(left_search, shared_context) + recreate_search_with_context(right_search, shared_context) + + shared_context + end + + def recreate_search_with_context(search, context) + # If it's a ChainedSearch, handle recursively + if search.is_a?(ChainedSearch) + recreate_search_with_context(search.left_search, context) + recreate_search_with_context(search.right_search, context) + search.instance_variable_set(:@context, context) + else + # For regular Search, we need to update its context + # This is a bit hacky but necessary for join consistency + search.instance_variable_set(:@context, context) + end + end + + def apply_scopes(relation, search) + if search.is_a?(ChainedSearch) + relation = apply_scopes(relation, search.left_search) + relation = apply_scopes(relation, search.right_search) + elsif search.respond_to?(:scope_args) && search.scope_args.any? + # Apply scopes from the search + search.scope_args.each do |scope_name, args| + if relation.respond_to?(scope_name) + relation = relation.public_send(scope_name, *args) + end + end + end + relation + end + end end diff --git a/spec/ransack/search_spec.rb b/spec/ransack/search_spec.rb index 26433199d..4d17dab49 100644 --- a/spec/ransack/search_spec.rb +++ b/spec/ransack/search_spec.rb @@ -742,5 +742,119 @@ def remove_quotes_and_backticks(str) expect(@s.groupings.first.children_name_eq).to eq 'Ernie' end end + + describe '#and and #or chainable methods' do + it 'provides and method that returns a ChainedSearch' do + s1 = Search.new(Person, name_eq: 'Ernie') + s2 = Search.new(Person, email_eq: 'ernie@example.com') + + chained = s1.and(s2) + expect(chained).to be_a(ChainedSearch) + expect(chained.combinator).to eq 'and' + expect(chained.left_search).to eq s1 + expect(chained.right_search).to eq s2 + end + + it 'provides or method that returns a ChainedSearch' do + s1 = Search.new(Person, name_eq: 'Ernie') + s2 = Search.new(Person, name_eq: 'Bert') + + chained = s1.or(s2) + expect(chained).to be_a(ChainedSearch) + expect(chained.combinator).to eq 'or' + expect(chained.left_search).to eq s1 + expect(chained.right_search).to eq s2 + end + + it 'supports chaining multiple searches with and' do + s1 = Search.new(Person, name_eq: 'Ernie') + s2 = Search.new(Person, email_eq: 'ernie@example.com') + s3 = Search.new(Person, salary_gt: 50000) + + chained = s1.and(s2).and(s3) + expect(chained).to be_a(ChainedSearch) + expect(chained.left_search).to be_a(ChainedSearch) + expect(chained.right_search).to eq s3 + end + + it 'supports chaining multiple searches with or' do + s1 = Search.new(Person, name_eq: 'Ernie') + s2 = Search.new(Person, name_eq: 'Bert') + s3 = Search.new(Person, name_eq: 'Kris') + + chained = s1.or(s2).or(s3) + expect(chained).to be_a(ChainedSearch) + expect(chained.left_search).to be_a(ChainedSearch) + expect(chained.right_search).to eq s3 + end + + it 'supports mixing and and or operations' do + s1 = Search.new(Person, name_eq: 'Ernie') + s2 = Search.new(Person, email_eq: 'ernie@example.com') + s3 = Search.new(Person, name_eq: 'Bert') + + chained = s1.and(s2).or(s3) + expect(chained).to be_a(ChainedSearch) + expect(chained.combinator).to eq 'or' + expect(chained.left_search).to be_a(ChainedSearch) + expect(chained.left_search.combinator).to eq 'and' + end + end + + describe 'ChainedSearch#result' do + it 'produces an ActiveRecord::Relation' do + s1 = Search.new(Person, name_eq: 'Ernie') + s2 = Search.new(Person, email_eq: 'ernie@example.com') + + result = s1.and(s2).result + expect(result).to be_a(ActiveRecord::Relation) + end + + it 'generates correct SQL for AND operations' do + s1 = Search.new(Person, name_eq: 'Ernie') + s2 = Search.new(Person, email_eq: 'ernie@example.com') + + sql = s1.and(s2).result.to_sql + expect(sql).to include('"people"."name" = \'Ernie\'') + expect(sql).to include('"people"."email" = \'ernie@example.com\'') + expect(sql).to include(' AND ') + end + + it 'generates correct SQL for OR operations' do + s1 = Search.new(Person, name_eq: 'Ernie') + s2 = Search.new(Person, name_eq: 'Bert') + + sql = s1.or(s2).result.to_sql + expect(sql).to include('"people"."name" = \'Ernie\'') + expect(sql).to include('"people"."name" = \'Bert\'') + expect(sql).to include(' OR ') + end + end + end + + describe 'ChainedSearch' do + describe '#inspect' do + it 'returns readable string representation' do + s1 = Search.new(Person, name_eq: 'Ernie') + s2 = Search.new(Person, name_eq: 'Bert') + + chained = s1.or(s2) + inspection = chained.inspect + expect(inspection).to include('ChainedSearch') + expect(inspection).to include('combinator: or') + end + end + + describe '#scope_args' do + it 'combines scope args from both searches' do + # This test would need scoped searches, which may not be available in test setup + # For now, just test the method exists and returns a hash + s1 = Search.new(Person, name_eq: 'Ernie') + s2 = Search.new(Person, name_eq: 'Bert') + + chained = s1.or(s2) + expect(chained.scope_args).to be_a(Hash) + end + end end end