Skip to content
Merged
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,27 @@ end

A parent with several such children sorts by the smallest value ascending and the largest descending. `:score => :min`, `:max`, `:avg` or `:total` combines them another way. Solr compares the values as floats, so times less than about two minutes apart, and integers above 2**24, can tie. Solr also reads a negative value as zero, so times before 1970 all sort as 1970, and negative numbers as zero. A later `order_by` breaks the tie.

#### Faceting on a child field

**Solr 7.7 and above**, for the `uniqueBlock` count: Solr 7.7 has it and 7.3 doesn't. The specs run it on Solr 8 and above.

`child_facet` facets on a field of the children, counting the parents that have each value rather than the children, and ranking the values by that count, so `:limit` keeps the values most parents have. It counts only the children of parents matching the search, and only those meeting the block's restrictions, the same restrictions `with_child` takes. A `with_child` on the search doesn't restrict the facet's children, so repeat any condition they need in the block.

```ruby
# The names of the milestones Ana owns on active projects, each with its number of projects
search = Project.search do
with :status, 'active'
child_facet :milestones, :name, :matches => 'des.*' do
with :owner_names, 'ana'
end
end
search.facet(:name).rows.map { |row| [row.value, row.count] } # => [["design", 1]]
```

* The facet is read with `facet`, by the field's name or by `:name`. A search with another facet of the same name raises `ArgumentError` unless `child_facet` has a `:name` of its own.
* Options: `:limit`, `:offset`, `:prefix`, `:missing`, `:all_buckets`, `:sort` (`:count`, the default, which ranks by the number of parents, or `:index`) with `:sort_type`, `:minimum_count`, counted in parents, and `:matches`. Any other option raises `ArgumentError`.
* `:matches` is a Lucene regular expression, not a Ruby one, matched against the whole value of a string field, case-sensitively. `Sunspot::Util.escape_lucene_regexp` escapes text to match it literally, e.g. text a user typed. On a multiple-value field, `:matches` selects children, so a matching child's other values are counted too.

#### Schema

The schema needs a `_root_` field with the same type as `id`. The bundled configset has one.
Expand Down
35 changes: 35 additions & 0 deletions sunspot/lib/sunspot/dsl/field_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,41 @@ def json_facet(*field_names)
end
end

#
# Facet on a field of the children in a nested association, counting the
# documents that have each value rather than the children, and ranking
# the values by that count. Only the children of documents matching the
# search are counted, and only those that satisfy every restriction in
# the block, which takes the same restrictions as DSL::Scope#with_child.
#
# The facet is read as <tt>search.facet(field_name)</tt>, or by its
# +:name+, which it needs when the search has another facet of that
# name. Options: +:limit+, +:offset+, +:prefix+, +:missing+,
# +:all_buckets+, +:sort+ (+:count+, the default, or +:index+) with
# +:sort_type+, +:minimum_count+ (of documents), and +:matches+: a
# Lucene regular expression the child's value of a string field must
# match as a whole, case-sensitively. On a multiple-value field,
# +:matches+ selects children, so a matching child's other values are
# counted too. Util.escape_lucene_regexp escapes text to match literally.
#
# ==== Example
#
# search = Sunspot.search(Project) do
# with :status, 'active'
# child_facet :milestones, :name, :matches => 'des.*' do
# with :owner_names, 'ana'
# end
# end
# search.facet(:name).rows # each row's count is a number of projects
#
def child_facet(name, field_name, options = {}, &block)
block_join, child_setup = build_block_join(false, name, &block)
field = child_setup.field(field_name.to_sym)
facet = Sunspot::Query::ChildFieldJsonFacet.new(field, options, child_setup, block_join)
@search.add_child_json_facet(field, options)
@query.add_query_facet(facet)
end

def stats(*field_names, &block)
options = Sunspot::Util.extract_options_from(field_names)

Expand Down
2 changes: 1 addition & 1 deletion sunspot/lib/sunspot/query.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
%w(filter abstract_field_facet abstract_json_field_facet connective boost_query date_field_facet field_json_facet
range_facet range_json_facet date_field_json_facet abstract_fulltext dismax join block_join
range_facet range_json_facet date_field_json_facet abstract_fulltext dismax join block_join child_field_json_facet
field_list field_facet highlighting pagination restriction common_query spellcheck
standard_query more_like_this more_like_this_query geo geofilt bbox query_facet
scope sort sort_composite text_field_boost function_query field_stats
Expand Down
2 changes: 2 additions & 0 deletions sunspot/lib/sunspot/query/abstract_json_field_facet.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require 'json' # to_params serializes the facet with to_json

module Sunspot
module Query
class AbstractJsonFieldFacet
Expand Down
6 changes: 4 additions & 2 deletions sunspot/lib/sunspot/query/block_join.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ def negate
self.class.new(@nested_setup, !negated?, @scope, @paths)
end

private

# The query matching the children this block join is about: those in
# its association that satisfy every restriction in #scope.
def child_query
@scope.to_boolean_phrase
end

private

def escape(value)
self.class.escape(value)
end
Expand Down
55 changes: 55 additions & 0 deletions sunspot/lib/sunspot/query/child_field_json_facet.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
module Sunspot
module Query
#
# A terms facet over the children of the matching documents: the children
# in one nested association that satisfy a block join's restrictions. Each
# row counts the documents with such a child, not the children, and the
# rows are ranked by that count.
#
# With +:matches+, only children whose value of the field matches it are
# counted. It is a Lucene regular expression, matched against the whole
# indexed value, case-sensitively. Util.escape_lucene_regexp escapes text
# to match literally.
#
class ChildFieldJsonFacet < FieldJsonFacet
OPTIONS = [:name, :limit, :offset, :sort, :sort_type, :minimum_count, :prefix, :missing, :all_buckets, :matches].freeze

# The count each row reports: the number of parents with such a child
PARENT_COUNT = 'distinct'.freeze

attr_reader :name

def initialize(field, options, setup, block_join)
unknown = options.keys - OPTIONS
raise ArgumentError, "child_facet doesn't take #{unknown.map(&:inspect).join(', ')}" if unknown.any?
unless field.type.is_a?(Sunspot::Type::StringType) || !options.key?(:matches)
raise ArgumentError, "child_facet's :matches needs a string field, but #{field.name} isn't one"
end
raise ArgumentError, "child_facet's :matches takes a Lucene regular expression as a String" if options.key?(:matches) && !options[:matches].is_a?(String)

super(field, options, setup)
@block_join = block_join
@name = (options[:name] || field.name).to_sym
end

def field_name_with_local_params
params = init_params.merge(:type => 'terms', :field => @field.indexed_name)
# Solr ranks buckets by its own count, the children, unless told to use the parent count
params[:sort] = { PARENT_COUNT => @options[:sort_type] || 'desc' } if @options[:sort].nil? || @options[:sort] == :count
params.delete(:mincount) # Solr's mincount counts children; rows apply :minimum_count to parents

filters = [@block_join.child_query]
filters << "#{@field.indexed_name}:/#{@options[:matches].gsub('/', '\\/')}/" if @options[:matches]

{ @name => params.merge(
:domain => { :blockChildren => BlockJoin::ROOTS, :filter => filters },
:facet => { PARENT_COUNT => 'uniqueBlock(_root_)' }
) }
end

def get_params
field_name_with_local_params
end
end
end
end
2 changes: 1 addition & 1 deletion sunspot/lib/sunspot/search.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
%w(abstract_search standard_search more_like_this_search query_facet
field_facet field_json_facet date_facet range_facet json_facet_stats
field_facet field_json_facet child_field_json_facet date_facet range_facet json_facet_stats
facet_row json_facet_row hit highlight field_group group hit_enumerable
stats_row stats_json_row field_stats stats_facet query_group).each do |file|
require File.join(File.dirname(__FILE__), 'search', file)
Expand Down
6 changes: 6 additions & 0 deletions sunspot/lib/sunspot/search/abstract_search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,12 @@ def add_json_facet(field, options = {})
add_facet(name, FieldJsonFacet.new(field, self, options))
end

def add_child_json_facet(field, options = {}) #:nodoc:
name = (options[:name] || field.name).to_sym
raise ArgumentError, "The search already has a facet named #{name}; give child_facet a :name of its own" if @facets_by_name.key?(name)
add_facet(name, ChildFieldJsonFacet.new(field, self, options))
end

def highlights_for(doc) #:nodoc:
if @solr_result['highlighting']
@solr_result['highlighting'][doc['id']]
Expand Down
28 changes: 28 additions & 0 deletions sunspot/lib/sunspot/search/child_field_json_facet.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module Sunspot
module Search
#
# The rows of a Query::ChildFieldJsonFacet, read by its name. Each row's
# count is a number of parents, and the rows come in Solr's order: by that
# count, unless the facet was sorted by :index.
#
class ChildFieldJsonFacet < FieldJsonFacet
def initialize(field, search, options)
super
@name = (options[:name] || field.name).to_sym
end

def rows
@rows ||= begin
data = no_data? ? [] : @search.json_facet_response[@name.to_s]['buckets']
rows = data.map {|bucket| JsonFacetRow.new(bucket, self) }
rows = rows.select {|row| row.count >= @options[:minimum_count] } if @options[:minimum_count]
rows
end
end

def no_data?
@search.json_facet_response[@name.to_s].nil?
end
end
end
end
9 changes: 9 additions & 0 deletions sunspot/lib/sunspot/util.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ def escape(value)
RSolr.solr_escape(value).gsub(/([\s\.])/, '\\\\\1')
end

#
# Escapes text for a Lucene regular expression, such as child_facet's
# +:matches+, so it matches literally. Leaves a slash, which child_facet
# escapes itself.
#
def escape_lucene_regexp(value)
value.to_s.gsub(/[.?+*|{}\[\]()"\\#@&<>~^$]/) {|char| "\\#{char}" }
end

def parse_json_facet(field_name, options, setup)
field = setup.field(field_name)
if options[:range] || options[:time_range]
Expand Down
62 changes: 62 additions & 0 deletions sunspot/spec/api/nested_documents_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,68 @@ def last_search
end
end

describe 'faceting on a child field' do
def json_facet
JSON.parse(connection.searches.last['json.facet'])
end

it 'facets on the children of the matching parents, ranked by the number of parents' do
session.search(Project) { child_facet(:milestones, :name) { with :owner_names, 'ana' } }
expect(json_facet).to eq('name' => {
'type' => 'terms', 'field' => 'name_s', 'sort' => { 'distinct' => 'desc' },
'domain' => { 'blockChildren' => roots, 'filter' => ['(_sunspot_nested_path_s:"Project.milestones" AND owner_names_sm:ana)'] },
'facet' => { 'distinct' => 'uniqueBlock(_root_)' }
})
end

it 'is keyed by its :name' do
session.search(Project) { child_facet(:milestones, :name, :name => :milestone_name) }
expect(json_facet.keys).to eq(['milestone_name'])
end

it 'ranks by the number of parents when sorted by count, and keeps an index sort' do
session.search(Project) { child_facet(:milestones, :name, :sort => :count, :sort_type => 'asc') }
expect(json_facet['name']['sort']).to eq('distinct' => 'asc')

session.search(Project) { child_facet(:milestones, :name, :sort => :index, :sort_type => 'asc') }
expect(json_facet['name']['sort']).to eq('index' => 'asc')
end

it 'leaves a minimum count to the rows, since Solr would apply it to the children' do
session.search(Project) { child_facet(:milestones, :name, :minimum_count => 2) }
expect(json_facet['name']).not_to have_key('mincount')
end

it 'filters the children by :matches, escaping a slash' do
session.search(Project) { child_facet(:milestones, :name, :matches => 'a/b.*') }
expect(json_facet['name']['domain']['filter'].last).to eq('name_s:/a\/b.*/')
end

it 'reads the children of every searched class that declares the association' do
session.search(Project, Program) { child_facet(:milestones, :name) }
expect(json_facet['name']['domain']['filter'].first).to include('Project.milestones', 'Program.milestones')
end

it 'raises for an option it does not support' do
expect { session.search(Project) { child_facet(:milestones, :name, :range => [1, 10]) } }.to raise_error(ArgumentError, /:range/)
end

it 'raises for :matches on a field that is not a string, or given anything but a String' do
expect { session.search(Project) { child_facet(:milestones, :started_at, :matches => '2026.*') } }.to raise_error(ArgumentError, /string field/)
expect { session.search(Project) { child_facet(:milestones, :name, :matches => /des/) } }.to raise_error(ArgumentError, /String/)
end

it 'raises for a name the search already has a facet by' do
expect { session.search(Project) { json_facet :name; child_facet(:milestones, :name) } }.to raise_error(ArgumentError, /:name of its own/)
end
end

describe 'escaping a Lucene regular expression' do
it 'escapes every character the syntax reserves, but a slash' do
expect(Sunspot::Util.escape_lucene_regexp('a.b (c) "d" <e> f&g ~h @i #j \\ k/l')).to eq('a\.b \(c\) \"d\" \<e\> f\&g \~h \@i \#j \\\\ k/l')
end
end

describe 'removal' do
it 'removes the whole block when removing a parent' do
project = Project.new
Expand Down
63 changes: 63 additions & 0 deletions sunspot/spec/integration/nested_documents_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,69 @@ def solr_count(query)
end
end

describe 'child_facet' do
before { skip_before_solr 8, 'The parent count needs uniqueBlock, which Solr 7.7 has and 7.3 lacks, and skip_before_solr compares major versions' }

def facet_rows(&block)
Sunspot.search(Project, &block).facet(:name).rows.map { |row| [row.value, row.count] }
end

it 'counts the parents with a child of each value, not the children' do
Sunspot.index!(Project.new(:name => 'built twice', :status => 'active', :milestones => [milestone('build', Time.utc(2026, 3, 1)), milestone('build', Time.utc(2026, 5, 1))]))

expect(facet_rows { child_facet(:milestones, :name) }).to match_array([['design', 2], ['build', 2]])
end

it 'counts only the children meeting every condition in the block' do
expect(facet_rows { child_facet(:milestones, :name) { with :owner_names, 'ana' } }).to match_array([['design', 1], ['build', 1]])
end

it 'counts only the children of parents matching the search' do
expect(facet_rows { with :name, 'designed earlier'; child_facet(:milestones, :name) }).to match_array([['design', 1], ['build', 1]])
end

it 'counts only the children whose value matches the regular expression' do
expect(facet_rows { child_facet(:milestones, :name, :matches => 'des.*') }).to eq([['design', 2]])
end

it 'matches a value containing a slash' do
Sunspot.index!(Project.new(:name => 'slashed', :status => 'active', :milestones => [milestone('a/b', Time.utc(2026, 3, 1))]))

expect(facet_rows { child_facet(:milestones, :name, :matches => 'a/b') }).to eq([['a/b', 1]])
end

it 'ranks the values by their number of parents, so a limit keeps the values most parents have' do
Sunspot.index!(Project.new(:name => 'reviewed thrice', :status => 'active', :milestones => [milestone('review', Time.utc(2026, 3, 1)), milestone('review', Time.utc(2026, 4, 1)), milestone('review', Time.utc(2026, 5, 1))]))

expect(facet_rows { child_facet(:milestones, :name, :limit => 1) }).to eq([['design', 2]])
end

it 'applies a minimum count to the number of parents' do
Sunspot.index!(Project.new(:name => 'built thrice', :status => 'active', :milestones => [milestone('build', Time.utc(2026, 3, 1)), milestone('build', Time.utc(2026, 4, 1)), milestone('build', Time.utc(2026, 5, 1))]))

expect(facet_rows { child_facet(:milestones, :name, :minimum_count => 3) }).to eq([])
expect(facet_rows { child_facet(:milestones, :name, :minimum_count => 2) }).to match_array([['design', 2], ['build', 2]])
end

it 'keeps two facets on the same field apart by their :name' do
search = Sunspot.search(Project) do
child_facet(:milestones, :name, :name => :anas) { with :owner_names, 'ana' }
child_facet(:milestones, :name, :name => :bens) { with :owner_names, 'ben' }
end

expect(search.facet(:anas).rows.map { |row| [row.value, row.count] }).to match_array([['design', 1], ['build', 1]])
expect(search.facet(:bens).rows.map { |row| [row.value, row.count] }).to eq([['design', 1]])
end

it 'counts the other values of a multiple-value field on a child that matches' do
Sunspot.index!(Project.new(:name => 'shared', :status => 'active', :milestones => [milestone('plan', Time.utc(2026, 3, 1), :owner_names => %w(cy dee))]))

rows = Sunspot.search(Project) { child_facet(:milestones, :owner_names, :matches => 'd.*') }.facet(:owner_names).rows

expect(rows.map(&:value)).to match_array(['cy', 'dee'])
end
end

describe 'order_by_child' do
before { skip_before_solr 8, 'Solr 6 and earlier reject the sort, and Solr 7 orders some parents differently' }

Expand Down
Loading