Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion docs/docs/going-further/merging-searches.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
147 changes: 146 additions & 1 deletion lib/ransack/search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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<left: #{@left_search.inspect}, right: #{@right_search.inspect}, combinator: #{@combinator}>"
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
114 changes: 114 additions & 0 deletions spec/ransack/search_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]')

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: '[email protected]')
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: '[email protected]')
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: '[email protected]')

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: '[email protected]')

sql = s1.and(s2).result.to_sql
expect(sql).to include('"people"."name" = \'Ernie\'')
expect(sql).to include('"people"."email" = \'[email protected]\'')
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
Loading