Skip to content

feat: Facet on a field of nested children with child_facet, counting their parents - #10

Merged
njakobsen merged 3 commits into
masterfrom
child-facet
Oct 28, 2026
Merged

njakobsen merged 3 commits into
masterfrom
child-facet

Conversation

@njakobsen

Copy link
Copy Markdown
Member

Adds child_facet: a facet on a field of nested children that counts their parents. A search can already filter parents by their children (with_child) and sort by them (order_by_child). This adds the missing piece: listing the values the children of the matching parents have, restricted to the children meeting some conditions. The use it's built for is suggesting the next value of a nested filter in Glint's search autocomplete.

search = Project.search 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, not milestones

What changes

  • DSL::FieldQuery#child_facet(association, field, options = {}, &block) adds a JSON terms facet:
    • Its domain is the blockChildren of the parents matching the search.
    • Its filter is the same child restrictions with_child builds from the block. BlockJoin#child_query is now public so both use one definition.
    • A uniqueBlock(_root_) sub-facet named distinct makes each row count parents. JsonFacetRow already reads a distinct sub-facet as the row's count.
  • :matches keeps only the children whose value matches a Lucene regular expression as a whole. JSON facets have no facet.matches, so it goes in the domain filter. The other json_facet options apply too.
  • The result is read with facet, by the field's name or by :name.
  • The README documents it beside order_by_child.

Solr 8 and above, for uniqueBlock. The new examples skip on older Solr, as the order_by_child examples do. I chose 8 as the cut-off, but only tested 9.10.1.

Testing

  • New examples in spec/integration/nested_documents_spec.rb:
    • It counts parents, not children.
    • It counts only the children meeting the block's restrictions.
    • It counts only the children of parents matching the search.
    • :matches, including a pattern containing a slash.
  • Red→green:
    • Without the uniqueBlock sub-facet, a project with two "build" milestones counts 3 instead of 2.
    • Without the block's restrictions, another owner's milestone is counted.
  • Full sunspot suite against solr:9.10.1 with ci/solr9: 1,542 examples, 0 failures, 6 pending.

Draft until the Glint autocomplete work that uses it is ready.

🤖 Generated with Claude Code

njakobsen and others added 3 commits October 28, 2026 01:17
…g 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
@njakobsen
njakobsen marked this pull request as ready for review October 28, 2026 10:17
@njakobsen
njakobsen merged commit 4449813 into master Oct 28, 2026
93 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant