Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Please mark backwards incompatible changes with an exclamation mark at the start

## [Unreleased]

### Added
- The `#union` method to `JayAPI::Elasticsearch::QueryBuilder`, which combines
two queries using a boolean `should` clause (equivalent to `OR`).

## [29.11.0] - 2026-09-03

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,50 @@ The different clauses of the query are merged like this:
These are merged, the result is a compound of the clauses in both objects.
Query clauses are merged with a boolean ``must`` query (equivalent to ``AND``)

#union
------

The ``union`` method combines two ``QueryBuilder`` objects into a single one.
Unlike the ``merge`` method, which combines query clauses with a boolean
``must`` query (equivalent to ``AND``), ``union`` combines their top-level query
clauses with a boolean ``should`` query (equivalent to ``OR``).

Example:

.. code-block:: ruby

users_query = QueryBuilder.new
.terms(field: 'user.id', terms: %w[kimchy elkbee])
.sort('user.created_at' => :desc)
.size(100)

bio_query = QueryBuilder.new
.query_string(query: "user.bio: painter OR poet")
.size(10)

combined_query = users_query.union(bio_query)

Would be equivalent to:

.. code-block:: ruby

combined_query = QueryBuilder.new
.sort('user.created_at' => :desc) # Kept from the users_query
.size(10) # From bio_query, replacing the first size

combined_query.query.bool.should do |bool_query|
bool_query.terms(field: 'user.id', terms: %w[kimchy elkbee])
bool_query.query_string(query: "user.bio: painter OR poet")
end

The ``from``, ``size``, ``source`` and ``collapse`` settings, as well as
``sort`` and ``aggregations``, are combined using the same rules as ``merge``.

.. note::

This method returns a new ``QueryBuilder`` object which can be further
modified without affecting the source objects.

aggregations
------------

Expand Down
26 changes: 24 additions & 2 deletions lib/jay_api/elasticsearch/query_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# A helper class to build simple and common queries for Elasticsearch.
# Queries are created with the Elasticsearch Query DSL:
# https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html
class QueryBuilder

Check warning on line 13 in lib/jay_api/elasticsearch/query_builder.rb

View workflow job for this annotation

GitHub Actions / lint

[rubocop] reported by reviewdog 🐶 Class has too many lines. [108/100] Raw Output: lib/jay_api/elasticsearch/query_builder.rb:13:5: R: Metrics/ClassLength: Class has too many lines. [108/100]
# @return [JayAPI::Elasticsearch::QueryBuilder::Aggregations]
attr_reader :aggregations

Expand Down Expand Up @@ -121,9 +121,31 @@
klass = self.class
raise TypeError, "Cannot merge #{klass} and #{other.class}" unless other.is_a?(klass)

merged_query = query.merge(other.query)

other.combine(
from: @from, size: @size, source: @source, collapse: @collapse,
sort: @sort, query: merged_query, aggregations: @aggregations
)
end

# Creates a new +QueryBuilder+ object which is the result of taking the
# union of the receiver with +other+.
# @param [JayAPI::Elasticsearch::QueryBuilder] other Another instance of
# +QueryBuilder+.
# @return [JayAPI::Elasticsearch::QueryBuilder] A new +QueryBuilder+, the
# result of the union of both objects.
# @see JayAPI::Elasticsearch::QueryClauses#union
# @raise [TypeError] If the given object is not a +QueryBuilder+.
def union(other)
klass = self.class
raise TypeError, "Cannot create a union of #{klass} and #{other.class}" unless other.is_a?(klass)

query_union = query.union(other.query)

other.combine(
from: @from, size: @size, source: @source, collapse: @collapse,
sort: @sort, query: @query, aggregations: @aggregations
sort: @sort, query: query_union, aggregations: @aggregations
)
end

Expand Down Expand Up @@ -160,7 +182,7 @@
combined.source = @source || source
combined.collapse = @collapse || collapse
combined.sort = sort.merge(@sort)
combined.query = query.merge(self.query)
combined.query = query
combined.aggregations = aggregations.merge(self.aggregations)
end
end
Expand Down
52 changes: 43 additions & 9 deletions lib/jay_api/elasticsearch/query_builder/query_clauses.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,24 @@ def clone
# @return [self] A new +QueryClauses+ object which is a combination of
# the receiver and the given object.
def merge(other)
klass = self.class
raise TypeError, "Cannot merge #{klass} with #{other.class}" unless other.is_a?(klass)
combine(other, __method__) do |combined|
combined.bool.merge!(top_level_clause).merge!(other.top_level_clause)
end
end

if other.empty?
clone
elsif empty?
other.clone
else
klass.new.tap do |merged|
merged.bool.merge!(top_level_clause).merge!(other.top_level_clause)
# Creates a new +QueryClauses+ object representing the union of the
# receiver and the given object. The individual top-query clauses are
# combined using a +should+ boolean clause.
# @param [JayAPI::Elasticsearch::QueryBuilder::QueryClauses] other The
# +QueryClauses+ object the receiver should be combined with.
# @return [JayAPI::Elasticsearch::QueryBuilder::QueryClauses] A new
# +QueryClauses+ object which is a combination of the receiver and
# the given object.
def union(other)
combine(other, __method__) do |combined|
combined.bool.should do |bool|
bool << top_level_clause
bool << other.top_level_clause
end
end
end
Expand Down Expand Up @@ -157,6 +165,32 @@ def single_top_level_clause!
'to use multiple clauses add a compound query, ' \
'for example: `bool`'
end

# Combines the current +QueryClauses+ object with another one using the
# specified action. The combination is performed by yielding a new
# +QueryClauses+ object to the given block, which should define how the
# combination is done.
# @param [JayAPI::Elasticsearch::QueryBuilder::QueryClauses] other The
# +QueryClauses+ object to combine with.
# @param [Symbol] action The action describing how the combination would
# be performed (used only for the error message when the combination
# is not possible).
# @yield [combined] A new +QueryClauses+ object in which the combination
# should be carried out by the block.
# @return [JayAPI::Elasticsearch::QueryBuilder::QueryClauses] The new
# combined +QueryClauses+ object.
def combine(other, action, &block)
klass = self.class
raise TypeError, "Cannot perform '#{action}' between #{klass} and #{other.class}" unless other.is_a?(klass)

if other.empty?
clone
elsif empty?
other.clone
else
klass.new.tap { |combined| block.call(combined) }
end
end
end
end
end
Expand Down
95 changes: 87 additions & 8 deletions spec/integration/jay_api/elasticsearch/query_builder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,8 @@
it_behaves_like '#to_h'
end

describe '#merge' do
subject(:method_call) { query_builder.merge(other) }

let(:other) { described_class.new }

context 'when the QueryBuilder has nested aggregations' do
shared_examples_for '#combine' do
context 'when the QueryBuilder objects have nested aggregations' do
before do
query_builder.aggregations.avg('overall_avg_price', field: 'price')
query_builder.aggregations.filter('hats') { |query| query.term(field: 'type', value: 'hat') }
Expand Down Expand Up @@ -227,7 +223,7 @@
end
end

context 'when the receiver has a simple clause and the mergee is empty' do
context 'when the receiver has a simple clause and other is empty' do
before do
query_builder.query.term(field: 'user.name', value: 'kimchy')
end
Expand All @@ -247,7 +243,7 @@
end
end

context 'when the receiver is empty and the mergee has a simple clause' do
context 'when the receiver is empty and other has a simple clause' do
before do
other.query.range(field: 'age', gte: 10, lte: 20)
end
Expand All @@ -266,6 +262,14 @@
expect(method_call.to_query).to eq(expected_query)
end
end
end

describe '#merge' do
subject(:method_call) { query_builder.merge(other) }

let(:other) { described_class.new }

it_behaves_like '#combine'

context 'when two simple queries are merged' do
before do
Expand Down Expand Up @@ -391,4 +395,79 @@
end
end
end

describe '#union' do
subject(:method_call) { query_builder.union(other) }

let(:other) { described_class.new }

it_behaves_like '#combine'

context 'when two simple queries are merged' do
before do
query_builder.query.term(field: 'user.name', value: 'kimchy')
other.query.range(field: 'age', gte: 10, lte: 20)
end

let(:expected_query) do
{
query: {
bool: {
should: [
{ term: { 'user.name' => { value: 'kimchy' } } },
{ range: { 'age' => { gte: 10, lte: 20 } } }
]
}
}
}
end

it "merges both query using a boolean 'should' (equivalent to OR)" do
expect(method_call.to_query).to eq(expected_query)
end
end

context 'when two boolean queries are merged' do
before do
query_builder.query.bool.must do |bool_query|
bool_query.term(field: 'user.name', value: 'kimchy')
bool_query.range(field: 'age', gte: 10, lte: 20)
end

other.query.bool.must_not do |bool_query|
bool_query.exists(field: 'user.pet')
end
end

let(:expected_query) do
{
query: {
bool: {
should: [
{
bool: {
must: [
{ term: { 'user.name' => { value: 'kimchy' } } },
{ range: { 'age' => { gte: 10, lte: 20 } } }
]
}
},
{
bool: {
must_not: [
{ exists: { field: 'user.pet' } }
]
}
}
]
}
}
}
end

it "merges the two queries with a boolean 'should' (equivalent to OR)" do
expect(method_call.to_query).to eq(expected_query)
end
end
end
end
Loading
Loading