Repository navigation
feat: Index associations as nested child documents and search them with block joins - #4
Merged
Merged
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>
…ents, and merge a repeated nested block
A fourth review found:
- `remove_all(Class)` and `remove(Class) { … }` ran `Setup.nested_under?`, which walked every registered setup through `get_inheritable_hash`, `parent` and `clazz`. If any registered class no longer resolved, as after a test removes a stubbed class, both raised `NameError`, even in an app with no nested associations at all, where they used to send only `type:Post`. `Setup.nested_declared?`, set by the first `nested` declaration, now returns the plain query at once when no class ever declared one, and each setup's check in the scan is wrapped so an unresolvable class counts as not nested.
- Declaring `nested :kids` twice on one class, as a concern and the model body might, kept only the second block's fields, unlike parent fields, which add up across setup blocks. A repeated declaration now adds to the existing `NestedSetup`.
- An association that listed the same record twice, as `has_many :through` can, sent two child documents with the same id. `NestedSetup#children_for` now returns each child once.
Each has a spec that fails on 162767d. New specs also cover `nested … :using`, `with_child` in a query facet row, and a facet excluding a `with_child` filter.
The README's warning about adding `_root_` to an existing core moves out of the nested section into its own subsection under "Running Solr in production environment", since it applies to every class. It now also says, as checked on Solr 9.10.1, that removing a document indexed before the field was added leaves it in place. The nested section notes the delete-by-query each removal of a nested record sends. `examples/solr7_core` gains `_root_`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… an inherited one 6ba0b98 made a second `nested :kids` on a class add its fields to the existing `NestedSetup`. It found that setup in `@nested_setups`, but reading a class's nested setups copies its superclass's entries into that table, so once a subclass had been indexed, searched or scanned during removal, a subclass `nested :kids` found the superclass's `NestedSetup` and added its fields to it. Every superclass child was then indexed with the subclass's fields, the reverse of the documented replacement. A repeated block is now added to the existing `NestedSetup` only when this class declared it; otherwise the class gets a new one, as before. The spec reads the subclass's setup before it declares the association again, and fails on 6ba0b98. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A comment pass, checking each comment the PR adds against the code, found: - `BlockJoin#child_query`'s comment said Solr would join documents of other classes to the next parent, which they can't be, since every document without the path marker is a root. It's gone, and `#scope`'s comment says what the path condition guarantees. - Several comments described only one branch: `Setup#add_nested` (same-class merge versus a subclass's own setup, and that a repeated declaration keeps the first one's options), `nested_anywhere?` / `nested_under?` / `ignoring_missing_constants` (unresolvable classes, the early return, the re-raised `NoMethodError`), `NestedSetup#path` (a redeclaring subclass), `CompositeNestedSetup#text_fields` and `#dynamic_field_factory`, `DSL::Scope#with_child` (the raises and multi-class coverage), and `DSL::Fields#nested` (same-class merge). - `Indexer#with_children` and `#add_child_documents` gain examples. `#remove_blocks` names the `_root_` delete and its batches, and the Solr versions it was checked against (8.11 and 9 delete children with their parent by id, 6 doesn't). - The README's nested section covers the same-class merge, and no longer calls the removal query "slower", which nothing measured. The instance-restriction error splits its semicolon into two sentences. 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(67 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 (31 examples): the child-document specs skip, and indexing a parent with children raisesNestedDocumentsNotSupportedError.master) against the same core._root_in the schema: the same failures asmasteragainst the same core (all from the local Solr 9 conversion of the configset), and none of its own.atomic_updates_spec.rbpasses.NameErrorfor apps without nested documents when a registered class no longer resolves, a repeatednesteddeclaration dropping the first block's fields, and duplicate child ids. Each finding has a spec that failed before its fix: delete by awith_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.with_child, passes against this branch.🤖 Generated with Claude Code
https://claude.ai/code/session_01TAYgm5SknHTiRNTgGA6LBY