Skip to content

feat: Index associations as nested child documents and search them with block joins - #4

Open
njakobsen wants to merge 8 commits into
masterfrom
nested-documents
Open

njakobsen wants to merge 8 commits into
masterfrom
nested-documents

Conversation

@njakobsen

@njakobsen njakobsen commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

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.

Sunspot.setup(Project) do
  nested :milestones do
    string :name
    time :started_at
  end
end

Sunspot.search(Project) do
  with_child :milestones do
    with :name, 'design'
    with(:started_at).between(Time.utc(2026, 1, 1)...Time.utc(2026, 4, 1))
  end
end
  • Indexing. Each record of a nested association 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_s marker naming the association. It has no type or class_name. Children are indexed only as part of their parent, so the parent has to be reindexed when they change.

  • Searching. with_child and without_child render 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.

    • Block mask: every document without the marker, so documents of other classes indexed between blocks are never treated as children.
    • Child query: always requires the marker, and each restriction in the block is joined directly to that condition. A negated restriction therefore can't match documents outside the association, and a block of only negations still matches.
  • Removal. Deleting a nested class's records also deletes their children:

    • After a delete by id, by _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) and remove_by_scope send one query, (query) OR _query_:"{!child …}", so parents and children go together even when the scope has a with_child condition. 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.

    • A with_child search of several classes covers every searched class that declares the association. Child fields resolve the way CompositeSetup resolves parent fields: a field is usable when every declaring class agrees on its Solr field name and type, and raises UnrecognizedFieldError when they conflict.
    • A subclass that declares an association again keeps its superclass's _sunspot_nested_path_s value, 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.

    • Atomic updates raise ArgumentError for a class with nested associations.
    • A nested block rejects boosts, id prefixes, joins and further nesting, and with(record) / without(record) inside with_child raise ArgumentError.
    • Nested documents need an RSolr that defines RSolr::Document::CHILD_DOCUMENT_KEY; indexing raises NestedDocumentsNotSupportedError otherwise.
    • A subclass that declares an association again replaces its fields rather than adding to them, so a superclass search reaches its children only through fields both declare the same way. The nested docs say so.
    • Reindexing a parent replaces its old block only on Solr 8 or later. Solr before 8 replaces a document with children by _root_ and one without by id, 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. The nested docs say so, and the two specs for those cases skip on Solr before 8. That includes the Solr 5.3.1 that sunspot_solr bundles 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 master into the fork and pinned json < 3 for the Rails 6.1/7.0 appraisals. #5 has merged, and this PR now targets master.

Test plan

  • spec/api/nested_documents_spec.rb and spec/integration/nested_documents_spec.rb (61 examples): pass on Solr 9.10.1 and 6.6.6 with UPDATE_FORMAT xml and json, 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 and defaultSearchField were 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.
  • Under the rsolr-1.x appraisal (27 examples): the child-document specs skip, and indexing a parent with children raises NestedDocumentsNotSupportedError.
  • Full suite under both appraisals on Solr 6.6.6: every failure also fails on Merge upstream master into the fork, and keep json below 3 for old Rails #5's branch (upstream master) against the same core.
  • Review: a review of the first version found two bugs and four gaps, and two re-reviews found problems in the fixes. Each finding has a spec that failed before its fix: delete by a with_child scope 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.
  • Glint's suite (https://github.com/culturecode/glint/pull/140) passes against this branch.
  • CI on a15caa2: 56 jobs pass.
  • Delete-by-id behaviour, checked by hand: on Solr 6.6.6 a child survives its parent's delete by id; on 9.10.1 both are removed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TAYgm5SknHTiRNTgGA6LBY

@njakobsen
njakobsen changed the base branch from master to sync-upstream-master October 6, 2026 07:09
njakobsen and others added 2 commits October 6, 2026 00:26
…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
njakobsen marked this pull request as ready for review October 6, 2026 07:56
njakobsen and others added 4 commits October 6, 2026 01:41
…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>
@njakobsen
njakobsen changed the base branch from sync-upstream-master to master October 6, 2026 09:03
njakobsen and others added 2 commits October 6, 2026 18:05
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>
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