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

## [Unreleased]

### Added
- Support for the `bucket_selector` pipeline aggregation in
`Elasticsearch::QueryBuilder::Aggregations`. This allows filtering
buckets based on computed metrics (e.g., filtering terms buckets by
aggregated values).

## [29.3.1] - 2025-12-15

### Fixed
Expand Down Expand Up @@ -35,7 +41,7 @@ Please mark backwards incompatible changes with an exclamation mark at the start
allows boolean clauses to be nested.
- `QueryBuilder#sort` can now receive either the direction of the sorting (`asc`
or `desc`) or a `Hash` with advanced sorting options. These are relayed
directly to Elasticsearch.
directly to Elasticsearch.

## [29.0.0] - 2025-08-28

Expand Down
14 changes: 14 additions & 0 deletions lib/jay_api/elasticsearch/query_builder/aggregations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
require_relative 'aggregations/date_histogram'
require_relative 'aggregations/filter'
require_relative 'aggregations/scripted_metric'
require_relative 'aggregations/bucket_selector'
require_relative 'aggregations/sum'
require_relative 'aggregations/max'
require_relative 'aggregations/terms'
Expand All @@ -20,7 +21,7 @@
module Elasticsearch
class QueryBuilder
# The list of aggregations to be included in an Elasticsearch query.
class Aggregations

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

View workflow job for this annotation

GitHub Actions / lint

[rubocop] reported by reviewdog 🐶 Class has too many lines. [111/100] Raw Output: lib/jay_api/elasticsearch/query_builder/aggregations.rb:24:7: C: Metrics/ClassLength: Class has too many lines. [111/100]

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

View workflow job for this annotation

GitHub Actions / lint

[rubocop] reported by reviewdog 🐶 Class has too many lines. [111/100] Raw Output: lib/jay_api/elasticsearch/query_builder/aggregations.rb:24:7: C: Metrics/ClassLength: Class has too many lines. [111/100]
extend Forwardable

def_delegators :aggregations, :any?, :none?
Expand Down Expand Up @@ -86,6 +87,19 @@
)
end

# Adds an +bucket_selector+ type aggregation. For information about the parameters
# @see JayAPI::Elasticsearch::QueryBuilder::Aggregations::BucketSelector#initialize
def bucket_selector(name, buckets_path:, script:, gap_policy: nil)
add(
::JayAPI::Elasticsearch::QueryBuilder::Aggregations::BucketSelector.new(
name,
buckets_path: buckets_path,
script: script,
gap_policy: gap_policy
)
)
end

# Adds a +max+ type aggregation. For information about the parameters
# @see JayAPI::Elasticsearch::QueryBuilder::Aggregations::Max#initialize
def max(name, field:)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

require_relative 'aggregation'

module JayAPI
module Elasticsearch
class QueryBuilder
class Aggregations
# Represents a +bucket_selector+ pipeline aggregation in Elasticsearch.
# Docs:
# https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-bucket-selector-aggregation.html
class BucketSelector < ::JayAPI::Elasticsearch::QueryBuilder::Aggregations::Aggregation
attr_reader :buckets_path, :script, :gap_policy

# @param [String] name The name used by Elasticsearch to identify the aggregation.
# @param [Hash,String] buckets_path Path(s) to the metrics in parent aggs.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space after the comma:

Suggested change
# @param [Hash,String] buckets_path Path(s) to the metrics in parent aggs.
# @param [Hash, String] buckets_path Path(s) to the metrics in parent aggs.

# Can be a Hash (multiple params) or a String (single path).
# @param [JayAPI::Elasticsearch::QueryBuilder::Script] script
# Script used to decide whether to keep each bucket.
# @param [String, nil] gap_policy Optional gap policy (e.g. "skip", "insert_zeros").
def initialize(name, buckets_path:, script:, gap_policy: nil)
super(name)

@buckets_path = buckets_path
@script = script
@gap_policy = gap_policy
end

# Bucket selector is a pipeline agg and cannot have nested aggregations.
# @raise [JayAPI::Elasticsearch::QueryBuilder::Aggregations::Errors::AggregationsError]
def aggs
no_nested_aggregations('Bucket Selector')
end

# @return [self] A copy of the receiver.
def clone
self.class.new(
name,
buckets_path: buckets_path.is_a?(Hash) ? buckets_path.dup : buckets_path,
script: script, # Script is immutable-ish, ok to reuse

Check warning on line 40 in lib/jay_api/elasticsearch/query_builder/aggregations/bucket_selector.rb

View workflow job for this annotation

GitHub Actions / lint

[rubocop] reported by reviewdog 🐶 Unnecessary spacing detected. Raw Output: lib/jay_api/elasticsearch/query_builder/aggregations/bucket_selector.rb:40:30: C: Layout/ExtraSpacing: Unnecessary spacing detected.

Check warning on line 40 in lib/jay_api/elasticsearch/query_builder/aggregations/bucket_selector.rb

View workflow job for this annotation

GitHub Actions / lint

[rubocop] reported by reviewdog 🐶 Unnecessary spacing detected. Raw Output: lib/jay_api/elasticsearch/query_builder/aggregations/bucket_selector.rb:40:30: C: Layout/ExtraSpacing: Unnecessary spacing detected.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please take care of the linter warning on this line.

gap_policy: gap_policy
)
end

# @return [Hash] The Hash representation of the +Aggregation+.
# Properly formatted for Elasticsearch.
def to_h
super do
{
bucket_selector: {
buckets_path: buckets_path,
script: script.to_h,
gap_policy: gap_policy
}.compact
}
end
end
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# frozen_string_literal: true

require 'jay_api/elasticsearch/query_builder/aggregations/bucket_selector'
require 'jay_api/elasticsearch/query_builder/script'

require_relative 'aggregation_shared'

RSpec.describe JayAPI::Elasticsearch::QueryBuilder::Aggregations::BucketSelector do
subject(:bucket_selector) { described_class.new(name, **constructor_params) }

let(:name) { 'expensive_genres' }

let(:buckets_path) do
{ 'avgPrice' => 'avg_price' }
end

let(:script) do
instance_double(
JayAPI::Elasticsearch::QueryBuilder::Script,
to_h: {
source: 'params.avgPrice > params.threshold',
lang: 'painless',
params: { threshold: 10 }
}
)
end

let(:constructor_params) do
{
buckets_path: buckets_path,
script: script
}
end

describe '#aggs' do
subject(:method_call) { bucket_selector.aggs }

let(:expected_message) { 'The Bucket Selector aggregation cannot have nested aggregations.' }

it_behaves_like 'JayAPI::Elasticsearch::QueryBuilder::Aggregations::Aggregation#no_nested_aggregations'
end

describe '#clone' do
subject(:method_call) { bucket_selector.clone }

it 'returns an instance of the same class' do
expect(method_call).to be_an_instance_of(described_class)
end

it 'does not return the same object' do
expect(method_call).not_to be(bucket_selector)
end

it "has the same 'name'" do
expect(method_call.name).to be(bucket_selector.name)
end

context "when 'buckets_path' is a String" do
let(:buckets_path) { 'avg_price' }

it "has the same 'buckets_path' (same object is fine)" do
expect(method_call.buckets_path).to be(bucket_selector.buckets_path).and eq('avg_price')
end
end

context "when 'buckets_path' is a Hash" do
let(:buckets_path) do
{ 'avgPrice' => 'avg_price' }
end

it "has an equal 'buckets_path' but not the same object" do
expect(method_call.buckets_path).to eq(bucket_selector.buckets_path)
expect(method_call.buckets_path).not_to be(bucket_selector.buckets_path)
end
end

it "has the same 'script'" do
expect(method_call.script).to be(bucket_selector.script).and be(script)
end

context "when no 'gap_policy' is given" do
it "has the same 'gap_policy' (nil)" do
expect(method_call.gap_policy).to be(bucket_selector.gap_policy).and be_nil
end
end

context "when a 'gap_policy' is given" do
let(:constructor_params) do
{
buckets_path: buckets_path,
script: script,
gap_policy: 'skip'
}
end

it "has the same 'gap_policy'" do
expect(method_call.gap_policy).to be(bucket_selector.gap_policy).and eq('skip')
end
end
end

describe '#to_h' do
subject(:method_call) { aggregation.to_h }

let(:aggregation) { bucket_selector }

it_behaves_like 'JayAPI::Elasticsearch::QueryBuilder::Aggregations::Aggregation#to_h'

context "when no 'gap_policy' is given" do
let(:expected_hash) do
{
'expensive_genres' => {
bucket_selector: {
buckets_path: {
'avgPrice' => 'avg_price'
},
script: {
source: 'params.avgPrice > params.threshold',
lang: 'painless',
params: { threshold: 10 }
}
}
}
}
end

it 'returns the expected Hash' do
expect(method_call).to eq(expected_hash)
end
end

context "when a 'gap_policy' is given" do
let(:constructor_params) do
{
buckets_path: buckets_path,
script: script,
gap_policy: 'skip'
}
end

let(:expected_hash) do
{
'expensive_genres' => {
bucket_selector: {
buckets_path: {
'avgPrice' => 'avg_price'
},
script: {
source: 'params.avgPrice > params.threshold',
lang: 'painless',
params: { threshold: 10 }
},
gap_policy: 'skip'
}
}
}
end

it 'returns the expected Hash (including the given gap_policy)' do
expect(method_call).to eq(expected_hash)
end
end
end
end
65 changes: 65 additions & 0 deletions spec/jay_api/elasticsearch/query_builder/aggregations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,71 @@
end
end

describe '#bucket_selector' do
subject(:method_call) do
aggregations.bucket_selector(
name,
buckets_path: buckets_path,
script: script,
gap_policy: gap_policy
)
end

let(:name) { 'only_slow_tests' }
let(:buckets_path) { { total: 'total_duration_ms' } }
let(:script) do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Please add an empty line between lines 519 and 520

instance_double(
JayAPI::Elasticsearch::QueryBuilder::Script
)
end
let(:gap_policy) { nil }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Please add an empty line between lines 524 and 525


let(:bucket_selector) do
instance_double(
JayAPI::Elasticsearch::QueryBuilder::Aggregations::BucketSelector,
to_h: { 'bucket_selector' => { '#to_h' => {} } }
)
end

before do
allow(JayAPI::Elasticsearch::QueryBuilder::Aggregations::BucketSelector)
.to receive(:new).and_return(bucket_selector)
end

it 'creates the BucketSelector instance with the expected parameters' do

Check warning on line 539 in spec/jay_api/elasticsearch/query_builder/aggregations_spec.rb

View workflow job for this annotation

GitHub Actions / lint

[rubocop] reported by reviewdog 🐶 Example has too many lines. [8/5] Raw Output: spec/jay_api/elasticsearch/query_builder/aggregations_spec.rb:539:5: C: RSpec/ExampleLength: Example has too many lines. [8/5]

Check warning on line 539 in spec/jay_api/elasticsearch/query_builder/aggregations_spec.rb

View workflow job for this annotation

GitHub Actions / lint

[rubocop] reported by reviewdog 🐶 Example has too many lines. [8/5] Raw Output: spec/jay_api/elasticsearch/query_builder/aggregations_spec.rb:539:5: C: RSpec/ExampleLength: Example has too many lines. [8/5]
expect(JayAPI::Elasticsearch::QueryBuilder::Aggregations::BucketSelector)
.to receive(:new).with(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please reformat this to avoid the RSpec/ExampleLength linter warning:

Suggested change
.to receive(:new).with(
.to receive(:new).with(name, buckets_path: buckets_path, script: script, gap_policy: gap_policy)

name,
buckets_path: buckets_path,
script: script,
gap_policy: gap_policy
)

method_call
end

context 'when a gap_policy is provided' do
let(:gap_policy) { 'skip' }

it 'creates the BucketSelector instance with the expected parameters' do

Check warning on line 554 in spec/jay_api/elasticsearch/query_builder/aggregations_spec.rb

View workflow job for this annotation

GitHub Actions / lint

[rubocop] reported by reviewdog 🐶 Example has too many lines. [8/5] Raw Output: spec/jay_api/elasticsearch/query_builder/aggregations_spec.rb:554:7: C: RSpec/ExampleLength: Example has too many lines. [8/5]

Check warning on line 554 in spec/jay_api/elasticsearch/query_builder/aggregations_spec.rb

View workflow job for this annotation

GitHub Actions / lint

[rubocop] reported by reviewdog 🐶 Example has too many lines. [8/5] Raw Output: spec/jay_api/elasticsearch/query_builder/aggregations_spec.rb:554:7: C: RSpec/ExampleLength: Example has too many lines. [8/5]
expect(JayAPI::Elasticsearch::QueryBuilder::Aggregations::BucketSelector)
.to receive(:new).with(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likewise here, please reflow the code to avoid the linter warning.

name,
buckets_path: buckets_path,
script: script,
gap_policy: 'skip'
)

method_call
end
end

it 'adds the BucketSelector instance to the array of aggregations' do
expect { method_call }.to change(aggregations, :to_h)
.to(aggs: { 'bucket_selector' => { '#to_h' => {} } })
end
end

describe '#to_h' do
subject(:method_call) { aggregations.to_h }

Expand Down