From c7d710ad5bb8b80ca421892101119e9e540059e8 Mon Sep 17 00:00:00 2001 From: Nicholas Jakobsen Date: Wed, 28 Oct 2026 01:17:30 -0700 Subject: [PATCH 1/3] feat: Facet on a field of nested children with `child_facet`, counting their parents Issue: a search could filter parents by their children (`with_child`) and sort by them (`order_by_child`), but not facet on a child field. Listing the values the children of the matching parents have, for example to suggest the next value of a nested filter, needed one: restricted to the children meeting the filter's other conditions, and counting parents rather than children. `child_facet :association, :field` adds a JSON terms facet whose domain is the `blockChildren` of the matching parents, filtered by the same restrictions `with_child` builds from the block (`BlockJoin#child_query`, now public). A `uniqueBlock(_root_)` sub-facet named `distinct` makes each row count parents, which `JsonFacetRow` already reads. `:matches` adds a Lucene regular expression the child's value must match. It needs Solr 8 and above for `uniqueBlock`. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++++++++ sunspot/lib/sunspot/dsl/field_query.rb | 28 ++++++++++++++++ sunspot/lib/sunspot/query.rb | 2 +- sunspot/lib/sunspot/query/block_join.rb | 6 ++-- .../sunspot/query/child_field_json_facet.rb | 32 +++++++++++++++++++ .../spec/integration/nested_documents_spec.rb | 30 +++++++++++++++++ 6 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 sunspot/lib/sunspot/query/child_field_json_facet.rb diff --git a/README.md b/README.md index 245cb6305..b79eb27ef 100644 --- a/README.md +++ b/README.md @@ -1090,6 +1090,19 @@ 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. +`child_facet` facets on a field of the children, counting the parents that have each value rather than the children. It counts only the children of parents matching the search, and only those meeting the block's restrictions. `:matches` keeps only the children whose value matches a Lucene regular expression, as a whole. It takes the other options of `json_facet`, and is read with `facet`, by the field's name or by `:name`. **Solr 8 and above**, for the `uniqueBlock` count. + +```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]] +``` + #### Schema The schema needs a `_root_` field with the same type as `id`. The bundled configset has one. diff --git a/sunspot/lib/sunspot/dsl/field_query.rb b/sunspot/lib/sunspot/dsl/field_query.rb index 52933253e..aca1c5f43 100644 --- a/sunspot/lib/sunspot/dsl/field_query.rb +++ b/sunspot/lib/sunspot/dsl/field_query.rb @@ -388,6 +388,34 @@ 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. 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 + # search.facet(field_name), or by its +:name+. + # + # Takes the options of #json_facet, plus +:matches+: a Lucene regular + # expression the child's value of the field must match as a whole. + # + # ==== 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) + @search.add_json_facet(field, options) + @query.add_query_facet(Sunspot::Query::ChildFieldJsonFacet.new(field, options, child_setup, block_join)) + end + def stats(*field_names, &block) options = Sunspot::Util.extract_options_from(field_names) diff --git a/sunspot/lib/sunspot/query.rb b/sunspot/lib/sunspot/query.rb index 6cdae57d7..c4d1437c8 100644 --- a/sunspot/lib/sunspot/query.rb +++ b/sunspot/lib/sunspot/query.rb @@ -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 diff --git a/sunspot/lib/sunspot/query/block_join.rb b/sunspot/lib/sunspot/query/block_join.rb index 47f158aac..7910896ae 100644 --- a/sunspot/lib/sunspot/query/block_join.rb +++ b/sunspot/lib/sunspot/query/block_join.rb @@ -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 diff --git a/sunspot/lib/sunspot/query/child_field_json_facet.rb b/sunspot/lib/sunspot/query/child_field_json_facet.rb new file mode 100644 index 000000000..54f0df3c5 --- /dev/null +++ b/sunspot/lib/sunspot/query/child_field_json_facet.rb @@ -0,0 +1,32 @@ +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. + # + # 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. + # + class ChildFieldJsonFacet < FieldJsonFacet + def initialize(field, options, setup, block_join) + super(field, options, setup) + @block_join = block_join + end + + def field_name_with_local_params + params = super + filters = [@block_join.child_query] + filters << "#{@field.indexed_name}:/#{@options[:matches].gsub('/', '\\/')}/" if @options[:matches] + + params[@field.name].merge!( + :domain => { :blockChildren => BlockJoin::ROOTS, :filter => filters }, + # JsonFacetRow counts a row by its distinct sub-facet when it has one + :facet => { :distinct => 'uniqueBlock(_root_)' } + ) + params + end + end + end +end diff --git a/sunspot/spec/integration/nested_documents_spec.rb b/sunspot/spec/integration/nested_documents_spec.rb index 595217e0c..9c095f128 100644 --- a/sunspot/spec/integration/nested_documents_spec.rb +++ b/sunspot/spec/integration/nested_documents_spec.rb @@ -105,6 +105,36 @@ def solr_count(query) end end + describe 'child_facet' do + before { skip_before_solr 8, 'The parent count needs uniqueBlock, which Solr 7 and earlier lack' } + + 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 'counts a regular expression containing a slash' do + expect(facet_rows { child_facet(:milestones, :name, :matches => 'a/b|design') }).to eq([['design', 2]]) + 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' } From 51b5c2a8f00d241b7471f69f5a7237fc0c72114c Mon Sep 17 00:00:00 2001 From: Nicholas Jakobsen Date: Wed, 28 Oct 2026 03:14:39 -0700 Subject: [PATCH 2/3] fix: Require JSON where JSON facets serialize their params Root cause: `Query::AbstractJsonFieldFacet#to_params` calls `to_json` on the facet's params without requiring `json`, so building a JSON facet raised `NoMethodError` unless something else had loaded JSON first. The integration specs passed only because RSolr loads it, and no API spec built a JSON facet's params. `abstract_json_field_facet.rb` now requires `json` itself. Co-Authored-By: Claude Opus 5.5 --- sunspot/lib/sunspot/query/abstract_json_field_facet.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sunspot/lib/sunspot/query/abstract_json_field_facet.rb b/sunspot/lib/sunspot/query/abstract_json_field_facet.rb index 34e094973..8c6d8c2e4 100644 --- a/sunspot/lib/sunspot/query/abstract_json_field_facet.rb +++ b/sunspot/lib/sunspot/query/abstract_json_field_facet.rb @@ -1,3 +1,5 @@ +require 'json' # to_params serializes the facet with to_json + module Sunspot module Query class AbstractJsonFieldFacet From 1b819c93ca38970e381daf5e9b3670a6e1d5254f Mon Sep 17 00:00:00 2001 From: Nicholas Jakobsen Date: Wed, 28 Oct 2026 03:14:39 -0700 Subject: [PATCH 3/3] fix: Rank `child_facet` by its parent count, key it by its name, and reject options it can't honour Root cause: a review of `child_facet` found that it reported each value's number of parents, but Solr still ranked, limited and applied a minimum count to the values by their number of children, so `:limit` could keep a value few parents have. It was keyed by its field's name in both the request and the response, so a `json_facet` or a second `child_facet` on a same-named field overwrote it, and `:name` didn't help. It silently ignored `json_facet` options it doesn't build, such as `:range`, `:distinct` and `:nested`, raised `NoMethodError` for a Ruby `Regexp` in `:matches`, and returned nothing for `:matches` on a field that isn't a string. It also claimed to need Solr 8, but Solr 7.7 has `uniqueBlock`. `child_facet` now sorts by the parent count unless sorted by `:index`, applies `:minimum_count` to parents when reading its rows (a new `Search::ChildFieldJsonFacet`, whose rows keep Solr's order), and is keyed by its `:name` or its field's name, raising `ArgumentError` when the search already has a facet by that name. It takes only the options it honours, and raises for anything else, for `:matches` that isn't a String or is on a field that isn't a string. `Sunspot::Util.escape_lucene_regexp` escapes text for `:matches`. API specs cover the request it builds, and the README gives it its own section, which states the Solr version and that `:matches` on a multiple-value field selects children, so a matching child's other values are counted too. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++- sunspot/lib/sunspot/dsl/field_query.rb | 27 +++++--- .../sunspot/query/child_field_json_facet.rb | 39 +++++++++--- sunspot/lib/sunspot/search.rb | 2 +- sunspot/lib/sunspot/search/abstract_search.rb | 6 ++ .../sunspot/search/child_field_json_facet.rb | 28 +++++++++ sunspot/lib/sunspot/util.rb | 9 +++ sunspot/spec/api/nested_documents_spec.rb | 62 +++++++++++++++++++ .../spec/integration/nested_documents_spec.rb | 39 +++++++++++- 9 files changed, 199 insertions(+), 23 deletions(-) create mode 100644 sunspot/lib/sunspot/search/child_field_json_facet.rb diff --git a/README.md b/README.md index b79eb27ef..55744dcc7 100644 --- a/README.md +++ b/README.md @@ -1090,7 +1090,11 @@ 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. -`child_facet` facets on a field of the children, counting the parents that have each value rather than the children. It counts only the children of parents matching the search, and only those meeting the block's restrictions. `:matches` keeps only the children whose value matches a Lucene regular expression, as a whole. It takes the other options of `json_facet`, and is read with `facet`, by the field's name or by `:name`. **Solr 8 and above**, for the `uniqueBlock` count. +#### 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 @@ -1103,6 +1107,10 @@ 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. diff --git a/sunspot/lib/sunspot/dsl/field_query.rb b/sunspot/lib/sunspot/dsl/field_query.rb index aca1c5f43..11287f436 100644 --- a/sunspot/lib/sunspot/dsl/field_query.rb +++ b/sunspot/lib/sunspot/dsl/field_query.rb @@ -390,14 +390,20 @@ def json_facet(*field_names) # # Facet on a field of the children in a nested association, counting the - # documents that have each value rather than the children. 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 - # search.facet(field_name), or by its +:name+. - # - # Takes the options of #json_facet, plus +:matches+: a Lucene regular - # expression the child's value of the field must match as a whole. + # 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 search.facet(field_name), 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 # @@ -412,8 +418,9 @@ def json_facet(*field_names) 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) - @search.add_json_facet(field, options) - @query.add_query_facet(Sunspot::Query::ChildFieldJsonFacet.new(field, options, child_setup, block_join)) + 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) diff --git a/sunspot/lib/sunspot/query/child_field_json_facet.rb b/sunspot/lib/sunspot/query/child_field_json_facet.rb index 54f0df3c5..d19de9093 100644 --- a/sunspot/lib/sunspot/query/child_field_json_facet.rb +++ b/sunspot/lib/sunspot/query/child_field_json_facet.rb @@ -3,29 +3,52 @@ 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. + # 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. + # 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 = super + 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] - params[@field.name].merge!( + { @name => params.merge( :domain => { :blockChildren => BlockJoin::ROOTS, :filter => filters }, - # JsonFacetRow counts a row by its distinct sub-facet when it has one - :facet => { :distinct => 'uniqueBlock(_root_)' } - ) - params + :facet => { PARENT_COUNT => 'uniqueBlock(_root_)' } + ) } + end + + def get_params + field_name_with_local_params end end end diff --git a/sunspot/lib/sunspot/search.rb b/sunspot/lib/sunspot/search.rb index 945fb7c4f..c07776c19 100644 --- a/sunspot/lib/sunspot/search.rb +++ b/sunspot/lib/sunspot/search.rb @@ -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) diff --git a/sunspot/lib/sunspot/search/abstract_search.rb b/sunspot/lib/sunspot/search/abstract_search.rb index ad71d1c3b..b0cdffb8f 100644 --- a/sunspot/lib/sunspot/search/abstract_search.rb +++ b/sunspot/lib/sunspot/search/abstract_search.rb @@ -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']] diff --git a/sunspot/lib/sunspot/search/child_field_json_facet.rb b/sunspot/lib/sunspot/search/child_field_json_facet.rb new file mode 100644 index 000000000..94fca276a --- /dev/null +++ b/sunspot/lib/sunspot/search/child_field_json_facet.rb @@ -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 diff --git a/sunspot/lib/sunspot/util.rb b/sunspot/lib/sunspot/util.rb index e83be0378..d244f06b8 100644 --- a/sunspot/lib/sunspot/util.rb +++ b/sunspot/lib/sunspot/util.rb @@ -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] diff --git a/sunspot/spec/api/nested_documents_spec.rb b/sunspot/spec/api/nested_documents_spec.rb index 15f457154..f3d5fd072 100644 --- a/sunspot/spec/api/nested_documents_spec.rb +++ b/sunspot/spec/api/nested_documents_spec.rb @@ -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" f&g ~h @i #j \\ k/l')).to eq('a\.b \(c\) \"d\" \ 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 diff --git a/sunspot/spec/integration/nested_documents_spec.rb b/sunspot/spec/integration/nested_documents_spec.rb index 9c095f128..a6d3b57ed 100644 --- a/sunspot/spec/integration/nested_documents_spec.rb +++ b/sunspot/spec/integration/nested_documents_spec.rb @@ -106,7 +106,7 @@ def solr_count(query) end describe 'child_facet' do - before { skip_before_solr 8, 'The parent count needs uniqueBlock, which Solr 7 and earlier lack' } + 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] } @@ -130,8 +130,41 @@ def facet_rows(&block) expect(facet_rows { child_facet(:milestones, :name, :matches => 'des.*') }).to eq([['design', 2]]) end - it 'counts a regular expression containing a slash' do - expect(facet_rows { child_facet(:milestones, :name, :matches => 'a/b|design') }).to eq([['design', 2]]) + 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