Repository navigation
Conversation
2 tasks done
njakobsen
force-pushed
the
nested-documents
branch
from
October 6, 2026 07:09
b158286 to
2329005
Compare
…th block joins
Issue: Sunspot indexes one flat document per record, so a search can't require that a single associated record meet several conditions together. Indexing an association's fields as multivalued fields on the parent loses which value came from which record: a parent with one milestone named "design" and another started in Q1 matches "a design milestone started in Q1".
`nested :milestones do … end` in a setup block now indexes each record of the association as a Solr child document inside its parent's block, with the fields declared in the block. Each child gets an id derived from its parent's (`"<parent id>/<association>/<child index id or position>"`) and a `_sunspot_nested_path_s` marker naming the association, and no `type` or `class_name`. A nested block rejects boosts, id prefixes, joins and further nesting.
`with_child :milestones do … end` and `without_child` scope a search to parents with, or without, a child meeting every restriction in the block. They render as a `{!parent}` block join embedded with `_query_`, so they work in filter queries, `any_of`/`all_of`, query facet rows and delete-by-query. The block mask is every document without the marker, so documents of other classes indexed between blocks are never taken for children, and the child query always requires the marker, so a negated restriction can't match documents outside the association. Each restriction in the block is joined directly to that marker condition, so a block of only negated restrictions still matches.
Removing a nested class's records also deletes their children: by `_root_` after a delete by id, since Solr 6 leaves children behind on a delete by id where Solr 9 removes them, and through a `{!child}` query before `remove_all` and `remove_by_scope`. Atomic updates raise `ArgumentError` for a class with nested associations. The bundled configset gains the `_root_` field, which Solr requires before it indexes a document with children.
Specs: 46 examples in `spec/api/nested_documents_spec.rb` and `spec/integration/nested_documents_spec.rb`, passing on Solr 6.6.6 and 9.10.1 with XML and JSON updates. The full suite's other failures on Solr 6 match master's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cuments CI runs every spec under the `rsolr-1.x` and `rsolr-2.x` appraisals, and RSolr 1.x has no `RSolr::Document`, so the nested-document specs that index children raised `NestedDocumentsNotSupportedError` there: 25 failures, all in `spec/api/nested_documents_spec.rb`'s indexing block and `spec/integration/nested_documents_spec.rb`. Those two blocks now run only when `RSolr::Document::CHILD_DOCUMENT_KEY` is defined. Under RSolr 1.x, new examples check that indexing a parent with children raises `NestedDocumentsNotSupportedError` and that a parent with no children still indexes. The atomic-update example moves out of the indexing block, since it needs no children and runs under both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
njakobsen
force-pushed
the
nested-documents
branch
from
October 6, 2026 07:26
2329005 to
1052e25
Compare
njakobsen
marked this pull request as ready for review
October 6, 2026 07:56
…tion across classes
Five problems a review of the nested documents found, each with a spec that failed before this change:
- `Sunspot.remove!(Project) { with_child … }` deleted the children and left the parents on Solr 9. The children were deleted first, by a separate query, so by the time the parent query ran its child condition matched nothing. `remove_all(Class)` and `remove_by_scope` now send one delete query, `(query) OR _query_:"{!child …}"`, which Solr evaluates once, so parents and their children go together.
- On Solr 6, reindexing a parent whose children went from some to none left the old children behind, and from none to some left two copies of the parent, doubling it in search results. Solr before 8 replaces a document with children by `_root_` and one without by `id`. `Indexer#add` now deletes each nested parent's previous block first: by id when it has children, by `_root_` when it has none.
- Removing through a class with no nested associations, such as `remove_all(Asset)`, left the children of a nested subclass like `Vehicle < Asset`. The children clause is now added whenever the removed class, or a subclass of it, declares a nested association (`Setup.nested_under?`), and left off otherwise, so other classes' removal queries are unchanged.
- A subclass that declared an association again marked its children with its own name, `SubProject.milestones`, so a `with_child` search on the superclass never found them. A redeclared association now keeps the superclass's `_sunspot_nested_path_s` value.
- A search of several classes that each declare the same association matched only the first class's children. The child query now accepts the path of every searched class that declares it (`Setup#nested_paths`).
`with(record)` and `without(record)` inside a `with_child` block restricted on the child document's id, which has the parent's id in front of it, so they silently matched nothing or everything. They now raise `ArgumentError`. `remove_by_id` for a class name that no longer resolves no longer raises `NameError`.
The nested specs pass on Solr 6.6.6 and 9.10.1 with XML and JSON updates (57 examples), and under the `rsolr-1.x` appraisal (26). Outside them, the full suite fails only where upstream `master` does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… every declaring class A second review of the nested documents found four problems in the previous commit: - `Setup.nested_under?` only scanned registered setups for subclasses of the removed class, and never asked whether the class itself inherits a nested association. Removing a subclass with no setup of its own, the usual single-table-inheritance shape (`Spinoff < Project`), left its children behind on both `remove_all` and `remove_by_scope`. It now checks the classes themselves first. - `Indexer#add` deleted each nested parent's previous block in a separate request before adding it. Inside `Sunspot.batch` the delete went out at once and the add waited for the flush, so an exception in the batch, or any commit before the flush, dropped the record from the index. The deletes also cost an extra update request on every index of a nested class, and on Solr 8 and later, where Solr replaces the block itself, they bought nothing. The pre-delete is gone. The `nested` documentation now says that replacing a block whose children went from some to none or none to some needs Solr 8, and the two specs for it skip on earlier versions. - `remove_blocks` built one `_root_:(…)` query for every id, which fails past Solr's limit of 1024 boolean clauses. It now sends batches of 500. - Fields inside `with_child` resolved against the first searched class that declared the association, so a field only a later class declared raised `UnrecognizedFieldError`, a field two classes declared with different types silently used the first's Solr field, and results depended on the order of the classes. A child block now resolves fields through `CompositeNestedSetup`, built from every `NestedSetup` the search covers, `Setup#nested_setups_named`: each searched class's own and those of registered subclasses that declare the association again. A field resolves when every setup that declares it agrees on its Solr field, as `CompositeSetup` does for parent fields, and raises otherwise. `nested_class_name?` no longer swallows a `NoMethodError`. Nested specs: 59 on Solr 9.10.1 and 6.6.6 (2 skipped on 6.6.6), xml and json, 27 under `rsolr-1.x`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent fields resolve
`Setup#nested_setups_named` added the redeclared nested setup of every registered subclass, so as soon as one subclass declared an association again, a search of the superclass alone resolved child fields through `CompositeNestedSetup`. With `Crate` declaring `nested :items { time :at; text :body; dynamic_string :attrs }` and `SubCrate < Crate` redeclaring `at` as a string, `with(:at)` on a `Crate` search raised "configured differently", `text_fields { … }` raised `NoMethodError` because the composite had no `text_fields`, and `dynamic(:attrs)` raised because the subclass didn't declare it. Which subclasses were registered depended on what had been loaded, so the same query could work in one process and raise in another.
A class's search now uses only its own nested setup, inherited if need be, the way a superclass search never consults subclass setups for parent fields. A redeclared association already keeps its superclass's path, so the subclass's children still match. `CompositeNestedSetup` is now used only for a search of several classes, and gains `text_fields` and `type_names`, which `TextFieldSetup` calls. Its field rule now compares the Solr field name and type, so a `time` and a `date` field with the same name are no longer taken for one. `Setup.all` and `Setup#declared_nested_setup` go with the scan, which also cost a pass over every registered setup on each `with_child`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s its fields A subclass's `nested` block replaces the association's fields for its children rather than adding to them, unlike parent-level setups, which add to what they inherit. A subclass that redeclared `nested :leaves` with only a new field indexed its children without the superclass's fields, so a superclass search on one of those fields silently skipped them. The `nested` documentation now says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a "Nested Documents (Block Joins)" section after "Joins": declaring an association with `nested`, searching with `with_child` and `without_child`, the `_root_` schema field and the full reindex that adding it to an existing index needs on Solr 8 and above, and the limits the code enforces or the specs record (Solr 8 for replacing a block on reindex, atomic updates, what a nested block rejects, subclass redeclaration, child ids). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s behind The README said documents indexed before `_root_` existed are "indexed twice", which read as if they had never been indexed. It now says what happens: Solr 8 and above replace a document by its `_root_` value once the schema has it, documents indexed earlier have none, so a reindex adds a new copy beside the old one and later reindexes replace only the new copy, and the old copies stay until `rake sunspot:reindex` deletes each class's documents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds Solr nested documents (block join) to Sunspot, so a search can require that one associated record meet several conditions together. With flat multivalued fields on the parent, "a design milestone started in Q1" also matches a parent with a design milestone and a different milestone started in Q1.
Indexing. Each record of a
nestedassociation becomes a child document inside its parent's block. A child has the fields declared in the block, an id derived from its parent's ("<parent id>/<association>/<child index id or position>"), and a_sunspot_nested_path_smarker naming the association. It has notypeorclass_name. Children are indexed only as part of their parent, so the parent has to be reindexed when they change.Searching.
with_childandwithout_childrender as a{!parent}block join embedded with_query_. They work anywhere a restriction does: filter queries,any_of/all_of, query facet rows, and delete-by-query.Removal. Deleting a nested class's records also deletes their children:
_root_, in batches of 500 ids to stay under Solr's boolean clause limit. Solr 6 leaves children behind on a delete by id; Solr 9 removes them.remove_all(Class)andremove_by_scopesend one query,(query) OR _query_:"{!child …}", so parents and children go together even when the scope has awith_childcondition. The children clause is added only when the removed class, an ancestor's setup it inherits, or a registered subclass declares a nested association.Several classes, and subclasses.
with_childsearch of several classes covers every searched class that declares the association. Child fields resolve the wayCompositeSetupresolves parent fields: a field is usable when every declaring class agrees on its Solr field name and type, and raisesUnrecognizedFieldErrorwhen they conflict._sunspot_nested_path_svalue, so a search of the superclass finds the subclass's children. That search resolves child fields from the superclass's own nested setup, as a superclass search does for parent fields.Limits.
ArgumentErrorfor a class with nested associations.with(record)/without(record)insidewith_childraiseArgumentError.RSolr::Document::CHILD_DOCUMENT_KEY; indexing raisesNestedDocumentsNotSupportedErrorotherwise.nesteddocs say so._root_and one without byid, so a parent whose children went from some to none keeps its old children there, and one whose children went from none to some is indexed twice. Thenesteddocs say so, and the two specs for those cases skip on Solr before 8. That includes the Solr 5.3.1 thatsunspot_solrbundles and CI runs, so those two specs skip in every CI job; they pass locally on Solr 9.10.1.Schema. The bundled configset gains
_root_. Solr refuses a document with children unless the schema defines_root_with the same field type as the unique key. On Solr 9.10.1 the error is "Unable to index docs with children: the schema must include definitions for both a uniqueKey field and the 'root' field, using the exact same fieldType".README. A new "Nested Documents (Block Joins)" section, after "Joins", covers the setup,
with_child/without_child, the_root_schema field and the reindex it needs, and the limits above.Base
Built on #5, which merged upstream
masterinto the fork and pinnedjson < 3for the Rails 6.1/7.0 appraisals. #5 has merged, and this PR now targetsmaster.Test plan
spec/api/nested_documents_spec.rbandspec/integration/nested_documents_spec.rb(61 examples): pass on Solr 9.10.1 and 6.6.6 withUPDATE_FORMATxmlandjson, in several random orders. On 6.6.6 the two Solr-8 reindex specs skip. The bundled configset doesn't load on Solr 9 (StandardFilterFactory,LatLonType,Trie*fields anddefaultSearchFieldwere removed), so the 9.10.1 runs used a local copy with those swapped for their Solr 9 equivalents. That copy isn't part of this PR.rsolr-1.xappraisal (27 examples): the child-document specs skip, and indexing a parent with children raisesNestedDocumentsNotSupportedError.master) against the same core.with_childscope leaving parents on Solr 9; removal through a subclass or superclass leaving children; a subclass's redeclared association invisible to a superclass search; multi-class searches matching or resolving fields by class order;with(record)inside a child block; the_root_delete passing the boolean clause limit; a subclass's redeclaration breaking superclass searches.🤖 Generated with Claude Code
https://claude.ai/code/session_01TAYgm5SknHTiRNTgGA6LBY