diff --git a/README.md b/README.md index f7ea3e2c8..010ed5057 100644 --- a/README.md +++ b/README.md @@ -1028,6 +1028,71 @@ end # qRss91753840: _query_:"{!field f=type}Rss"+_query_:"{!edismax qf='keywords_text'}keyword3" ``` +### Nested Documents (Block Joins) + +**Solr 8 and above recommended; requires RSolr 2** + +Nested documents let a search 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 project with one milestone named "design" and another started in Q1 would match "a design milestone started in Q1". `nested` indexes each record of an association as a Solr child document inside its parent's block, and `with_child` / `without_child` search them with Solr's [block join query parsers](https://solr.apache.org/guide/solr/latest/query-guide/block-join-query-parser.html). + +```ruby +class Project < ActiveRecord::Base + has_many :milestones + + searchable do + string :status + + nested :milestones do + string :name + time :started_at + string(:owner_names, :multiple => true) { owners.map(&:name) } + end + end +end + +# Projects with a design milestone started in Q1. One milestone has to match +# both conditions. +Project.search do + with_child :milestones do + with :name, 'design' + with(:started_at).between(Time.utc(2026, 1, 1)...Time.utc(2026, 4, 1)) + end +end + +# Projects with no design milestone, including projects with no milestones +Project.search do + without_child :milestones do + with :name, 'design' + end +end + +# Projects with any milestone at all +Project.search { with_child :milestones } +``` + +The block inside `with_child` / `without_child` takes the same restrictions as a search, including `without`, `any_of`, `all_of`, `dynamic` and `text_fields`, with field names referring to the children's fields. `with_child` combines with restrictions on the parent and works inside `any_of`, query facets and `remove`. + +Searching several classes covers each class that declares the association, and a child field resolves as parent fields do: when every class declaring it agrees on its type. + +#### Schema + +The schema needs a `_root_` field with the same type as `id`. The bundled configset has one. + +```xml + +``` + +Adding `_root_` to a core that already holds documents needs a full reindex of every class on Solr 8 and above; see [Adding `_root_` to an existing core](#adding-_root_-to-an-existing-core). + +#### Things to know + +* Children are indexed only as part of their parent, so reindex the parent whenever its children change. +* Reindexing a parent replaces its whole block on Solr 8 and above. On earlier versions, including the Solr that `sunspot_solr` bundles, a parent whose children went from some to none keeps its old children, and one whose children went from none to some is indexed twice. Remove such a parent before reindexing it there. +* Atomic updates raise `ArgumentError` for a class with nested associations. Index the whole record instead. +* Removing a record of a nested class also sends a delete-by-query on `_root_`, because Solr before 8 leaves the children behind on a delete by id. Solr 8 and above delete them anyway, so there it's an extra request per removal. +* A nested block can't declare a boost, an id prefix, a join or a nested association of its own, and `with(record)` / `without(record)` inside `with_child` raise `ArgumentError`. +* Declaring an association again in the same class adds to its fields. A subclass that declares an inherited association again replaces its fields. A search of the superclass still reaches the subclass's children, but only through fields both declare the same way. +* A child document's id is `"//"`, where the child id is its Sunspot index id, or its position in the association when its class has no Sunspot adapter. Children carry a `_sunspot_nested_path_s` field naming their association and no `type`, so searches for the parent class never return them. + ### Composite ID **SolrCloud only** @@ -1678,6 +1743,10 @@ solr: where the `./solr/init` directory contains a shell script that does any initial setup like downloading and unzipping your cores. In both cases, the solr images by default expects cores to be placed in `/opt/solr/server/solr/mycores`. +### Adding `_root_` to an existing core + +The bundled configset and `examples/solr7_core` define a `_root_` field, which [nested documents](#nested-documents-block-joins) need. If you bring an existing core's schema up to date with them on Solr 8 or above, the change affects every class, nested or not. Once the schema has `_root_`, Solr replaces and deletes documents by their `_root_` value, and documents indexed before the field was added have none. Reindexing one of them adds a new copy beside the old one, later reindexes replace only the new copy, and removing it by id leaves the old copy in place. The old copies stay until they're deleted, so clear and reindex every class (`rake sunspot:reindex`, which deletes each class's documents first) right after adding the field. + ## Development ### Running Tests diff --git a/examples/solr7_core/conf/schema.xml b/examples/solr7_core/conf/schema.xml index 11c16cffc..50200d8e8 100644 --- a/examples/solr7_core/conf/schema.xml +++ b/examples/solr7_core/conf/schema.xml @@ -133,6 +133,9 @@ + + + diff --git a/sunspot/lib/sunspot.rb b/sunspot/lib/sunspot.rb index 72798f82a..6581112bb 100644 --- a/sunspot/lib/sunspot.rb +++ b/sunspot/lib/sunspot.rb @@ -12,7 +12,7 @@ require File.join(File.dirname(__FILE__), 'light_config') -%w(util adapters configuration setup composite_setup text_field_setup field +%w(util adapters configuration setup nested_setup composite_setup composite_nested_setup text_field_setup field field_factory data_extractor indexer query search session session_proxy type dsl class_set).each do |filename| require File.join(File.dirname(__FILE__), 'sunspot', filename) @@ -38,6 +38,7 @@ module Sunspot UnrecognizedRestrictionError = Class.new(StandardError) NoAdapterError = Class.new(StandardError) NoSetupError = Class.new(StandardError) + NestedDocumentsNotSupportedError = Class.new(StandardError) IllegalSearchError = Class.new(StandardError) NotImplementedError = Class.new(StandardError) AtomicUpdateRequireInstanceForCompositeIdMessage = lambda do |class_name| diff --git a/sunspot/lib/sunspot/composite_nested_setup.rb b/sunspot/lib/sunspot/composite_nested_setup.rb new file mode 100644 index 000000000..0759104b1 --- /dev/null +++ b/sunspot/lib/sunspot/composite_nested_setup.rb @@ -0,0 +1,74 @@ +module Sunspot + # + # The fields of one association's children across the NestedSetups of the + # classes in a multi-class search. A field resolves when every setup that + # declares it agrees on its Solr field name and type, as with CompositeSetup, + # and raises UnrecognizedFieldError when none declares it or they declare it + # differently. + # + class CompositeNestedSetup #:nodoc: + def initialize(nested_setups) + @nested_setups = nested_setups + end + + def field(field_name) + fields = @nested_setups.map do |setup| + begin + setup.field(field_name) + rescue UnrecognizedFieldError + nil + end + end.compact.uniq { |field| [field.indexed_name, field.type.class] } + return fields.first if fields.one? + + raise( + UnrecognizedFieldError, + fields.empty? ? "No field configured for #{paths} with name '#{field_name}'" : + "Field '#{field_name}' is configured differently for #{paths}" + ) + end + + # Returns the text fields with the given name, one per distinct Solr field. + # Raises UnrecognizedFieldError when no setup declares it. TextFieldSetup + # raises when there is more than one. + def text_fields(field_name) + fields = @nested_setups.flat_map do |setup| + begin + setup.text_fields(field_name) + rescue UnrecognizedFieldError + [] + end + end.uniq(&:indexed_name) + return fields if fields.any? + + raise UnrecognizedFieldError, "No text field configured for #{paths} with name '#{field_name}'" + end + + def type_names + @nested_setups.map(&:path).uniq + end + + # Returns the dynamic field factory with the given base name. Unlike + # #field, it requires every setup to declare it, as CompositeSetup does + # for parents' dynamic fields. Raises UnrecognizedFieldError when one + # doesn't, or when they build different Solr fields. + def dynamic_field_factory(field_name) + factories = @nested_setups.map { |setup| setup.dynamic_field_factory(field_name) } + return factories.first if factories.map { |factory| factory.build('x').indexed_name }.uniq.one? + + raise UnrecognizedFieldError, "Dynamic field '#{field_name}' is configured differently for #{paths}" + end + + def nested_setups_named(name) + raise UnrecognizedFieldError, "No nested association configured for #{paths} with name '#{name}'" + end + + alias_method :nested_setup, :nested_setups_named + + private + + def paths + type_names * ', ' + end + end +end diff --git a/sunspot/lib/sunspot/composite_setup.rb b/sunspot/lib/sunspot/composite_setup.rb index 4090f1427..8dd8a8f03 100644 --- a/sunspot/lib/sunspot/composite_setup.rb +++ b/sunspot/lib/sunspot/composite_setup.rb @@ -101,6 +101,40 @@ def dynamic_field_factory(field_name) ) end + # + # Returns the NestedSetup of each enclosed type that declares the given + # association. Raises UnrecognizedFieldError when none does. + # + def nested_setups_named(name) + nested_setups = setups.flat_map do |setup| + begin + setup.nested_setups_named(name) + rescue UnrecognizedFieldError + [] + end + end.uniq + nested_setups.empty? ? [nested_setup(name)] : nested_setups + end + + # + # Returns the NestedSetup for the given association from the first of the + # searched types that declares it. Raises UnrecognizedFieldError when none + # does. + # + def nested_setup(name) + setups.each do |setup| + begin + return setup.nested_setup(name) + rescue UnrecognizedFieldError + next + end + end + raise( + UnrecognizedFieldError, + "No nested association configured for #{@types * ', '} with name '#{name}'" + ) + end + # # Collection of all text fields configured for any of the enclosed types. # diff --git a/sunspot/lib/sunspot/dsl/fields.rb b/sunspot/lib/sunspot/dsl/fields.rb index dd107fff5..a153f8d60 100644 --- a/sunspot/lib/sunspot/dsl/fields.rb +++ b/sunspot/lib/sunspot/dsl/fields.rb @@ -71,6 +71,54 @@ def id_prefix(attr_name = nil, &block) @setup.add_id_prefix(attr_name, &block) end + # + # Indexes the records of an association as child documents of this + # class's documents, in the same Solr block. Fields declared in the block + # are indexed on each child, and a field's block is evaluated against the + # child record. Use DSL::Scope#with_child to find parents by conditions + # that a single child must meet together. + # + # Children are indexed only as part of their parent, so the parent must + # be reindexed whenever its children change. Atomic updates to a class + # with nested associations raise ArgumentError. + # + # 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, and one whose children went from none to some + # is indexed twice. Remove such a parent before reindexing it there. + # + # Declaring an association again in the same class adds the block's + # fields to it. A subclass that declares an inherited association again + # replaces its fields: its children are indexed with only the fields of + # its own block. They keep the superclass's association marker, so a + # search of the superclass still reaches them, but only through fields + # both blocks declare the same way. + # + # The block cannot declare a document boost, an id prefix, a join, or a + # nested association of its own. Each raises ArgumentError. + # + # ==== Parameters + # + # name:: The association, called on the parent to get the children + # + # ==== Options + # + # :using:: Method to call on the parent instead of +name+ + # + # ==== Example + # + # Sunspot.setup(Project) do + # nested :milestones do + # string :name + # time :started_at + # end + # end + # + def nested(name, options = {}, &block) + @setup.add_nested(name, options, &block) + end + # method_missing is used to provide access to typed fields, because # developers should be able to add new Sunspot::Type implementations # dynamically and have them recognized inside the Fields DSL. Like #text, @@ -119,5 +167,28 @@ def method_missing(method, *args, &block) end end end + + # + # The fields DSL inside a DSL::Fields#nested block. Rejects document + # boosts, id prefixes, joins, and nested associations with ArgumentError. + # + class NestedFields < Fields #:nodoc: + def boost(*) + raise ArgumentError, "Document boosts are not supported on nested documents" + end + + def id_prefix(*) + raise ArgumentError, "ID prefixes are not supported on nested documents, which take their parent's" + end + + def nested(*) + raise ArgumentError, "Nested documents cannot have nested documents of their own" + end + + def method_missing(method, *args, &block) + raise ArgumentError, "Joins are not supported on nested documents" if method.to_s == 'join' + super + end + end end end diff --git a/sunspot/lib/sunspot/dsl/scope.rb b/sunspot/lib/sunspot/dsl/scope.rb index a1830988c..14af387a5 100644 --- a/sunspot/lib/sunspot/dsl/scope.rb +++ b/sunspot/lib/sunspot/dsl/scope.rb @@ -97,6 +97,50 @@ def without(*args) add_restriction(true, *args) end + # + # Scope the results to documents with at least one child in the given + # association that satisfies every restriction in the block. Without a + # block, any child in the association matches. The association is one + # declared with DSL::Fields#nested. + # + # The block takes the same restrictions as a scope, with field names + # referring to the child's fields. Restricting by instance, as in + # with(record), raises ArgumentError, and a nested #with_child + # raises UnrecognizedFieldError. + # + # In a search of several classes, the block matches the children of + # every searched class that declares the association. + # + # ==== Example + # + # 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 + # + def with_child(name, &block) + add_block_join(false, name, &block) + end + + # + # Scope the results to documents with no child in the given association + # that satisfies every restriction in the block. Documents with no + # children in the association match. Without a block, only those match. + # + # ==== Example + # + # Sunspot.search(Project) do + # without_child :milestones do + # with :name, 'launch' + # end + # end + # + def without_child(name, &block) + add_block_join(true, name, &block) + end + # # Create a disjunction, scoping the results to documents that match any # of the enclosed restrictions. @@ -197,6 +241,14 @@ def text_fields(&block) private + def add_block_join(negated, name, &block) + nested_setups = @setup.nested_setups_named(name) + child_setup = nested_setups.one? ? nested_setups.first : CompositeNestedSetup.new(nested_setups) + block_join = Sunspot::Query::BlockJoin.new(nested_setups.first, negated, nil, nested_setups.map(&:path).uniq) + Util.instance_eval_or_call(Scope.new(block_join.scope, child_setup), &block) if block + @scope.add_component(block_join) + end + def add_restriction(negated, *args) case args.first when String, Symbol @@ -209,6 +261,9 @@ def add_restriction(negated, *args) DSL::Restriction.new(field, @scope, negated) end else # args are instances + if @setup.is_a?(NestedSetup) || @setup.is_a?(CompositeNestedSetup) + raise ArgumentError, "Instance restrictions are not supported inside with_child or without_child. Restrict on the child's fields instead" + end @scope.add_restriction( negated, IdField.instance, diff --git a/sunspot/lib/sunspot/indexer.rb b/sunspot/lib/sunspot/indexer.rb index 0703e3e7b..e0952ab1e 100644 --- a/sunspot/lib/sunspot/indexer.rb +++ b/sunspot/lib/sunspot/indexer.rb @@ -41,13 +41,17 @@ def add_atomic_update(clazz, updates={}) add_batch_documents(documents) end + # REMOVE_BLOCKS_BATCH_SIZE keeps each +_root_+ delete query under Solr's + # default limit of 1024 boolean clauses. + REMOVE_BLOCKS_BATCH_SIZE = 500 + # # Remove the given model from the Solr index # def remove(*models) - @connection.delete_by_id( - models.map { |model| Adapters::InstanceAdapter.adapt(model).index_id } - ) + ids = models.map { |model| Adapters::InstanceAdapter.adapt(model).index_id } + @connection.delete_by_id(ids) + remove_blocks(ids.select.with_index { |_, i| nested?(models[i].class) }) end # @@ -70,9 +74,9 @@ def remove_by_id(class_name, *ids) end ids.flatten! - @connection.delete_by_id( - ids.map { |id| Adapters::InstanceAdapter.index_id_for("#{id_prefix}#{class_name}", id) } - ) + index_ids = ids.map { |id| Adapters::InstanceAdapter.index_id_for("#{id_prefix}#{class_name}", id) } + @connection.delete_by_id(index_ids) + remove_blocks(index_ids) if nested_class_name?(class_name) end # @@ -80,7 +84,7 @@ def remove_by_id(class_name, *ids) # def remove_all(clazz = nil) if clazz - @connection.delete_by_query("type:#{Util.escape(clazz.name)}") + @connection.delete_by_query(with_children("type:#{Util.escape(clazz.name)}", [clazz])) else @connection.delete_by_query("*:*") end @@ -89,8 +93,8 @@ def remove_all(clazz = nil) # # Remove all documents that match the scope given in the Query # - def remove_by_scope(scope) - @connection.delete_by_query(scope.to_boolean_phrase) + def remove_by_scope(scope, types = []) + @connection.delete_by_query(with_children(scope.to_boolean_phrase, types)) end # @@ -125,10 +129,107 @@ def prepare_full_update(model) setup.all_field_factories.each do |field_factory| field_factory.populate_document(document, model) end + setup.nested_setups.each do |nested_setup| + add_child_documents(document, nested_setup, model) + end document end + # + # Adds a child document to +document+ for each of the model's children in + # +nested_setup+'s association, so Solr indexes the parent and its + # children as one block. Raises NestedDocumentsNotSupportedError when there + # are children and RSolr cannot send child documents. + # + # A child's id is the parent's id followed by the association name and + # #child_key, such as "Project 1/milestones/Milestone 7", so it is + # unique within the index and carries the parent's id prefix. + # + def add_child_documents(document, nested_setup, model) + children = nested_setup.children_for(model) + return if children.empty? + ensure_child_documents_supported! + + parent_id = document.field_by_name(:id).value + children.each_with_index do |child, position| + child_document = RSolr::Xml::Document.new( + id: "#{parent_id}/#{nested_setup.name}/#{child_key(child, position)}", + NestedSetup::PATH_FIELD.to_sym => nested_setup.path + ) + nested_setup.all_field_factories.each do |field_factory| + field_factory.populate_document(child_document, child) + end + document.add_field(RSolr::Document::CHILD_DOCUMENT_KEY, child_document) + end + end + + # Returns the child's index id when it has an adapter, so a child document + # can be traced back to its record. Returns its position in the + # association otherwise. + def child_key(child, position) + Adapters::InstanceAdapter.adapt(child).index_id + rescue NoAdapterError + position + end + + def ensure_child_documents_supported! + return if defined?(RSolr::Document::CHILD_DOCUMENT_KEY) + raise NestedDocumentsNotSupportedError, "Nested documents require an RSolr version that defines RSolr::Document::CHILD_DOCUMENT_KEY" + end + + def nested?(clazz) + setup = Setup.for(clazz) + !setup.nil? && setup.nested_setups.any? + end + + # Returns true when the named class has nested associations, or when it + # no longer exists and any class does, since its documents may have + # children. + def nested_class_name?(class_name) + nested?(Util.full_const_get(class_name)) + rescue NameError => e + raise if e.is_a?(NoMethodError) + Setup.nested_anywhere? + end + + # + # Deletes every document in the blocks rooted at the given ids, by + # +_root_+, in batches of REMOVE_BLOCKS_BATCH_SIZE. Solr 8.11 and 9 delete a + # parent's children along with it on a delete by id, and Solr 6 leaves + # them in the index. A child left behind is joined to whichever parent + # follows it in the index. + # + def remove_blocks(ids) + ids.each_slice(REMOVE_BLOCKS_BATCH_SIZE) do |batch| + @connection.delete_by_query("_root_:(#{batch.map { |id| %Q("#{Util.escape(id)}") }.join(' OR ')})") + end + end + + # + # Returns a query matching the documents +query+ matches and, when one of + # +classes+ or a subclass of one has nested associations, their children + # too. Returns +query+ unchanged otherwise. + # + # with_children("type:Project", [Project]) + # # => (type:Project) OR _query_:"{!child of=\"*:* -_sunspot_nested_path_s:[* TO *]\" v=\"type:Project\"}" + # + # It is one query, so a delete by it removes the parents and their + # children together. A +query+ with a DSL::Scope#with_child condition + # still matches its parents, since the children it refers to are deleted + # by the same query. + # + def with_children(query, classes) + return query unless Setup.nested_under?(classes) + escape = Query::BlockJoin.method(:escape) + children = %Q({!child of="#{escape.(Query::BlockJoin::ROOTS)}" v="#{escape.(query)}"}) + %Q((#{query}) OR _query_:"#{escape.(children)}") + end + def prepare_atomic_update(clazz, id, updates = {}) + if nested?(clazz) + raise ArgumentError, "Atomic updates are not supported for #{clazz.name}, which has nested documents. " \ + "Index the whole record instead, which reindexes its children with it" + end document = document_for_atomic_update(clazz, id) setup_for_class(clazz).all_field_factories.each do |field_factory| if updates.has_key?(field_factory.name) diff --git a/sunspot/lib/sunspot/nested_setup.rb b/sunspot/lib/sunspot/nested_setup.rb new file mode 100644 index 000000000..3935fec60 --- /dev/null +++ b/sunspot/lib/sunspot/nested_setup.rb @@ -0,0 +1,61 @@ +module Sunspot + # + # The field setup for the children of one association declared with + # DSL::Fields#nested. Children are indexed only as part of their parent's + # block, so a nested setup has fields but no class of its own. Child + # documents carry no +type+ or +class_name+ field. PATH_FIELD names their + # association instead. + # + class NestedSetup < Setup #:nodoc: + # PATH_FIELD marks each child document with the association it belongs + # to. A document without it is the root of a block. Child queries are + # scoped on it, which keeps children of other associations and documents + # of other classes out of the block join. + PATH_FIELD = '_sunspot_nested_path_s'.freeze + + attr_reader :name, :parent_setup + + def initialize(parent_setup, name, options = {}, path = nil) + @parent_setup = parent_setup + @name = name.to_sym + @class_name = "#{parent_setup.type_names.first}.#{@name}" + @path = path || @class_name + @field_factories, @text_field_factories, @dynamic_field_factories, + @field_factories_cache, @text_field_factories_cache, + @dynamic_field_factories_cache = *Array.new(6) { Hash.new } + @stored_field_factories_cache = Hash.new { |h, k| h[k] = [] } + @more_like_this_field_factories_cache = Hash.new { |h, k| h[k] = [] } + @nested_setups = {} + @dsl = DSL::NestedFields.new(self) + @children_extractor = DataExtractor::AttributeExtractor.new(options[:using] || @name) + end + + # + # Returns the value stored in PATH_FIELD on every child of this + # association: the class that first declared it and the association name, + # such as "Project.milestones". A subclass that declares the association + # again keeps its superclass's path. + # + def path + @path + end + + # Returns the model's non-nil children, each once, so an association that + # lists a record twice doesn't give two child documents the same id. + # Children that compare equal, such as two Structs with the same + # attributes, count as one. + def children_for(model) + Util.Array(@children_extractor.value_for(model)).compact.uniq + end + + def clazz + raise NotImplementedError, "Nested association #{path} has no class of its own" + end + + protected + + def parent + nil + end + end +end diff --git a/sunspot/lib/sunspot/query.rb b/sunspot/lib/sunspot/query.rb index c3248bcee..6cdae57d7 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 + range_facet range_json_facet date_field_json_facet abstract_fulltext dismax join block_join 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 new file mode 100644 index 000000000..47f158aac --- /dev/null +++ b/sunspot/lib/sunspot/query/block_join.rb @@ -0,0 +1,80 @@ +module Sunspot + module Query + # + # A restriction matching parents with at least one child in a nested + # association that satisfies every restriction in #scope. Negated, it + # matches parents with no such child. + # + # Renders as a Solr block join embedded with +_query_+, so it can sit in a + # filter query, a connective, a query facet row or a delete-by-query like + # any other restriction. + # + class BlockJoin #:nodoc: + include Filter + + # ROOTS is Solr's block mask: every document without + # NestedSetup::PATH_FIELD, whatever its class. A document of another class + # indexed between blocks is therefore a root to Solr, never a child. + ROOTS = "*:* -#{NestedSetup::PATH_FIELD}:[* TO *]".freeze + + # The restrictions a child must satisfy. Its first component is the + # condition on NestedSetup::PATH_FIELD, and every restriction added to it + # is ANDed with that condition. The child query therefore matches only + # this association's children, and a negated restriction can't match a + # parent or a child of another association. + attr_reader :scope + + def initialize(nested_setup, negated = false, scope = nil, paths = [nested_setup.path]) + @nested_setup, @negated, @paths = nested_setup, negated, paths + @scope = scope || Connective::Conjunction.new.tap { |conjunction| conjunction.add_component(PathRestriction.new(paths)) } + end + + # Escapes twice: the local param values, then the whole +_query_+ phrase. + def to_boolean_phrase + phrase = %Q(_query_:"#{escape(%Q({!parent which="#{escape(ROOTS)}" v="#{escape(child_query)}"}))}") + negated? ? "-#{phrase}" : phrase + end + + def negated? + @negated + end + + def negate + self.class.new(@nested_setup, !negated?, @scope, @paths) + end + + private + + def child_query + @scope.to_boolean_phrase + end + + def escape(value) + self.class.escape(value) + end + + # Backslash-escapes quotes and backslashes for a quoted Solr string: + # say "hi" becomes say \"hi\". + def self.escape(value) + value.gsub(/(["\\])/, '\\\\\1') + end + + # The condition matching the children of a nested association, on any + # of the given PATH_FIELD values. + class PathRestriction #:nodoc: + def initialize(paths) + @paths = paths + end + + def to_boolean_phrase + phrases = @paths.map { |path| %Q(#{NestedSetup::PATH_FIELD}:"#{BlockJoin.escape(path)}") } + phrases.one? ? phrases.first : "(#{phrases.join(' OR ')})" + end + + def negated? + false + end + end + end + end +end diff --git a/sunspot/lib/sunspot/session.rb b/sunspot/lib/sunspot/session.rb index 947a858c1..846691574 100644 --- a/sunspot/lib/sunspot/session.rb +++ b/sunspot/lib/sunspot/session.rb @@ -145,7 +145,7 @@ def remove(*objects, &block) end dsl = DSL::Scope.new(conjunction, setup_for_types(types)) Util.instance_eval_or_call(dsl, &block) - indexer.remove_by_scope(conjunction) + indexer.remove_by_scope(conjunction, types) else objects.flatten! @deletes += objects.length diff --git a/sunspot/lib/sunspot/setup.rb b/sunspot/lib/sunspot/setup.rb index 1cba5f071..7ad9a6a1a 100644 --- a/sunspot/lib/sunspot/setup.rb +++ b/sunspot/lib/sunspot/setup.rb @@ -13,6 +13,7 @@ def initialize(clazz) @dynamic_field_factories_cache = *Array.new(6) { Hash.new } @stored_field_factories_cache = Hash.new { |h, k| h[k] = [] } @more_like_this_field_factories_cache = Hash.new { |h, k| h[k] = [] } + @nested_setups = {} @dsl = DSL::Fields.new(self) @document_boost_extractor = nil @id_prefix_extractor = nil @@ -50,6 +51,56 @@ def add_join_field_factory(name, type, options = {}, &block) end end + # + # Declares an association whose records are indexed as child documents of + # this class's documents. Returns the NestedSetup the block's fields are + # added to. + # + # Declaring the same association again on this class adds the block's + # fields to it, as repeated setup blocks add parent fields, and keeps the + # first declaration's options. A subclass that declares an inherited + # association gets a NestedSetup of its own, with only its block's fields + # and its superclass's path. + # + def add_nested(name, options = {}, &block) + Setup.nested_declared! + # @nested_setups also holds entries get_inheritable_hash copied from the + # superclass, so only a NestedSetup this class created is added to. + existing = @nested_setups[name.to_sym] + nested_setup = existing && existing.parent_setup.equal?(self) ? existing : begin + inherited = parent && parent.get_inheritable_hash(:nested_setups)[name.to_sym] + NestedSetup.new(self, name, options, inherited && inherited.path) + end + nested_setup.setup(&block) if block + @nested_setups[nested_setup.name] = nested_setup + end + + # + # Returns the NestedSetup for the given association, including + # associations declared on a superclass. Raises UnrecognizedFieldError when + # no such association is declared. + # + def nested_setup(name) + get_inheritable_hash(:nested_setups)[name.to_sym] || raise( + UnrecognizedFieldError, + "No nested association configured for #{@class_name} with name '#{name}'" + ) + end + + def nested_setups + collection_from_inheritable_hash(:nested_setups) + end + + # + # Returns the NestedSetups a search of this class covers for the given + # association: its own, inherited if need be, as a one-element array to + # match CompositeSetup#nested_setups_named. Raises UnrecognizedFieldError + # when this class has no such association. + # + def nested_setups_named(name) + [nested_setup(name)] + end + # # Add field_factories for fulltext search # @@ -393,6 +444,44 @@ def for(clazz) #:nodoc: setups[clazz.name.to_sym] || self.for(clazz.superclass) if clazz end + # Returns true when any class's setup has a nested association. A setup + # whose class no longer resolves counts as having none. + def nested_anywhere? #:nodoc: + nested_declared? && setups.values.any? { |setup| ignoring_missing_constants { setup.nested_setups.any? } } + end + + # Records that a class has declared a nested association. Until one has, + # #nested_anywhere? and #nested_under? return false without scanning + # the registered setups. + def nested_declared! #:nodoc: + @nested_declared = true + end + + def nested_declared? #:nodoc: + !!@nested_declared + end + + # Returns true when one of the given classes, or a registered subclass of + # one, has a nested association, declared or inherited. A setup whose + # class no longer resolves counts as having none. + def nested_under?(classes) #:nodoc: + return false unless nested_declared? + return true if classes.any? { |type| (setup = self.for(type)) && setup.nested_setups.any? } + setups.values.any? do |setup| + ignoring_missing_constants { setup.nested_setups.any? && classes.any? { |type| setup.clazz <= type } } + end + end + + # Returns the block's result, or false when a setup's class, or one of + # its ancestors, no longer resolves to a constant, as after a test + # removes a stubbed class. A NoMethodError still raises. + def ignoring_missing_constants #:nodoc: + yield + rescue NameError => e + raise if e.is_a?(NoMethodError) + false + end + protected # diff --git a/sunspot/spec/api/nested_documents_spec.rb b/sunspot/spec/api/nested_documents_spec.rb new file mode 100644 index 000000000..d06f03adb --- /dev/null +++ b/sunspot/spec/api/nested_documents_spec.rb @@ -0,0 +1,316 @@ +require File.expand_path('spec_helper', File.dirname(__FILE__)) + +# RSolr 1.x has no RSolr::Document, so it can't send child documents +child_documents = defined?(RSolr::Document::CHILD_DOCUMENT_KEY) + +describe 'nested documents' do + let(:connection) { Mock::Connection.new } + let(:session) { Sunspot::Session.new(Sunspot::Configuration.build, connection) } + let(:roots) { '*:* -_sunspot_nested_path_s:[* TO *]' } + + def last_fq + connection.searches.last[:fq] + end + + describe 'setup' do + it 'resolves child fields against the association, not the parent' do + nested_setup = Sunspot::Setup.for(Project).nested_setup(:milestones) + expect(nested_setup.field(:started_at).indexed_name).to eq('started_at_d') + expect { nested_setup.field(:status) }.to raise_error(Sunspot::UnrecognizedFieldError) + end + + it "keeps each association's fields separate" do + setup = Sunspot::Setup.for(Project) + expect(setup.nested_setups.map(&:name)).to eq([:milestones, :reviews]) + expect { setup.nested_setup(:milestones).field(:verdict) }.to raise_error(Sunspot::UnrecognizedFieldError) + end + + it 'names each association by its declaring class' do + expect(Sunspot::Setup.for(Project).nested_setup(:milestones).path).to eq('Project.milestones') + end + + it 'raises for an association that was never declared' do + expect { Sunspot::Setup.for(Project).nested_setup(:tasks) }.to raise_error(Sunspot::UnrecognizedFieldError) + end + + it 'rejects boosts, nested associations and joins in a nested block' do + expect { Sunspot::Setup.for(Project).add_nested(:x) { boost 2 } }.to raise_error(ArgumentError) + expect { Sunspot::Setup.for(Project).add_nested(:x) { nested(:y) {} } }.to raise_error(ArgumentError) + expect { Sunspot::Setup.for(Project).add_nested(:x) { join(:y, :target => Post, :type => :string, :join => { :from => :a, :to => :b }) } }.to raise_error(ArgumentError) + end + end + + describe 'indexing', :if => child_documents do + let(:milestone) { Milestone.new(:name => 'design', :started_at => Time.utc(2026, 2, 1), :owner_names => %w(ana ben)) } + let(:project) { Project.new(:name => 'Bridge', :milestones => [milestone], :reviews => [OpenStruct.new(:verdict => 'approved')]) } + + def indexed_project + session.index(project) + connection.adds.last.first + end + + def children(document) + document.fields_by_name(RSolr::Document::CHILD_DOCUMENT_KEY).map(&:value) + end + + it 'sends children inside the parent document' do + expect(children(indexed_project).length).to eq(2) + end + + it 'gives each child an id derived from its parent, and its own index id when it has an adapter' do + milestone_doc = children(indexed_project).first + expect(milestone_doc.field_by_name(:id).value).to eq("Project #{project.id}/milestones/Milestone #{milestone.id}") + end + + it 'falls back to the position in the association for children without an adapter' do + review_doc = children(indexed_project).last + expect(review_doc.field_by_name(:id).value).to eq("Project #{project.id}/reviews/0") + end + + it 'marks each child with its association and gives it no type of its own' do + milestone_doc = children(indexed_project).first + expect(milestone_doc.field_by_name(:_sunspot_nested_path_s).value).to eq('Project.milestones') + expect(milestone_doc.field_by_name(:type)).to be_nil + expect(milestone_doc.field_by_name(:class_name)).to be_nil + end + + it 'indexes the child fields, evaluating blocks against the child' do + milestone_doc = children(indexed_project).first + expect(milestone_doc.field_by_name(:name_s).value).to eq('design') + expect(milestone_doc.field_by_name(:started_at_d).value).to eq('2026-02-01T00:00:00Z') + expect(milestone_doc.fields_by_name(:owner_names_sm).map(&:value)).to eq(%w(ana ben)) + expect(milestone_doc.field_by_name(:days_late_i).value).to eq('0') + end + + it 'keeps the parent fields on the parent' do + expect(indexed_project.field_by_name(:name_s).value).to eq('Bridge') + end + + it 'sends a record the association lists twice as one child' do + session.index(Project.new(:name => 'Twice', :milestones => [milestone, milestone])) + expect(children(connection.adds.last.first).length).to eq(1) + end + + it 'sends no children for an empty association' do + session.index(Project.new(:name => 'Empty')) + expect(children(connection.adds.last.first)).to be_empty + end + + end + + describe 'indexing with an RSolr that has no child documents', :unless => child_documents do + it 'raises for a parent with children' do + project = Project.new(:name => 'Bridge', :milestones => [Milestone.new(:name => 'design')]) + expect { session.index(project) }.to raise_error(Sunspot::NestedDocumentsNotSupportedError) + end + + it 'indexes a parent with no children' do + session.index(Project.new(:name => 'Empty')) + expect(connection.adds.last.first.field_by_name(:name_s).value).to eq('Empty') + end + end + + describe 'atomic updates' do + it 'refuses atomic updates to a class with nested associations' do + expect { session.atomic_update(Project, 1 => { :name => 'x' }) }.to raise_error(ArgumentError, /nested documents/) + end + end + + describe 'declaring the same association twice on one class' do + it 'keeps the fields of both blocks' do + Object.const_set(:TwiceDeclared, Class.new(MockRecord)) + Sunspot.setup(TwiceDeclared) { nested(:kids) { string :a } } + Sunspot.setup(TwiceDeclared) { nested(:kids) { string :b } } + + expect(Sunspot::Setup.for(TwiceDeclared).nested_setup(:kids).fields.map(&:name)).to match_array([:a, :b]) + ensure + Object.send(:remove_const, :TwiceDeclared) + end + end + + describe 'a subclass declaring an association again after its setup has been read' do + it "keeps the subclass's fields off the superclass's children" do + Object.const_set(:ReadBase, Class.new(MockRecord)) + Object.const_set(:ReadSub, Class.new(ReadBase)) + Sunspot.setup(ReadBase) { nested(:kids) { string :name } } + Sunspot.setup(ReadSub) { string :title } + Sunspot::Setup.for(ReadSub).nested_setups # copies the inherited entry into ReadSub's own table + Sunspot.setup(ReadSub) { nested(:kids) { string :only_sub } } + + expect(Sunspot::Setup.for(ReadBase).nested_setup(:kids).fields.map(&:name)).to eq([:name]) + expect(Sunspot::Setup.for(ReadSub).nested_setup(:kids).fields.map(&:name)).to eq([:only_sub]) + ensure + Object.send(:remove_const, :ReadSub) + Object.send(:remove_const, :ReadBase) + end + end + + describe 'querying' do + it 'finds parents by conditions a single child meets together' do + session.search(Project) do + with_child :milestones do + with :name, 'design' + with(:started_at).greater_than(Time.utc(2026, 1, 1)) + end + end + expect(last_fq).to include( + %q(_query_:"{!parent which=\\"*:* -_sunspot_nested_path_s:[* TO *]\\" v=\\"(_sunspot_nested_path_s:\\\\\\"Project.milestones\\\\\\" AND name_s:design AND started_at_d:{2026\\\\\\\\-01\\\\\\\\-01T00\\\\\\\\:00\\\\\\\\:00Z TO *})\\"}") + ) + end + + it 'scopes a block with no restrictions to the association alone' do + session.search(Project) { with_child(:milestones) } + expect(last_fq.last).to include('v=\\"_sunspot_nested_path_s:\\\\\\"Project.milestones\\\\\\"\\"') + end + + it 'negates with without_child' do + session.search(Project) { without_child(:milestones) { with :name, 'launch' } } + expect(last_fq.last).to start_with('-_query_:"{!parent') + end + + it 'joins each negated restriction directly to the association condition' do + session.search(Project) { with_child(:milestones) { without :name, 'launch'; without :name, 'design' } } + expect(last_fq.last).to include('Project.milestones\\\\\\" AND -name_s:launch AND -name_s:design)') + end + + it 'rejects an instance restriction inside a child block' do + milestone = Milestone.new(:name => 'design') + expect { session.search(Project) { with_child(:milestones) { with(milestone) } } }.to raise_error(ArgumentError, /Instance restrictions/) + expect { session.search(Project) { with_child(:milestones) { without(milestone) } } }.to raise_error(ArgumentError, /Instance restrictions/) + end + + it 'works in a query facet row' do + session.search(Project) do + facet :designed do + row(:yes) { with_child(:milestones) { with :name, 'design' } } + end + end + expect(Array(connection.searches.last[:'facet.query'])).to include(a_string_starting_with('_query_:"{!parent')) + end + + it 'can be excluded from a facet' do + session.search(Project) do + designed = with_child(:milestones) { with :name, 'design' } + facet :status, :exclude => designed + end + tag = last_fq.last[/\A\{!tag=([^}]+)\}/, 1] + expect(tag).not_to be_nil + expect(last_fq.last).to include('_query_:"{!parent') + expect(connection.searches.last[:'facet.field']).to include("{!ex=#{tag}}status_s") + end + + it 'combines with parent restrictions as separate filters' do + session.search(Project) do + with :status, 'active' + with_child(:milestones) { with :name, 'design' } + end + expect(last_fq).to include('status_s:active') + expect(last_fq.last).to start_with('_query_:"{!parent') + end + + it 'works inside any_of, including negated alternatives' do + session.search(Project) do + any_of do + with_child(:milestones) { with :name, 'design' } + without_child(:milestones) { with :name, 'launch' } + end + end + expect(last_fq.last).to match(/\A-\(-_query_:"\{!parent .*name_s:design.* AND _query_:"\{!parent .*name_s:launch.*\)\z/) + end + + it 'supports connectives inside the block' do + session.search(Project) do + with_child :milestones do + any_of do + with :name, 'design' + with :name, 'build' + end + end + end + expect(last_fq.last).to include('(name_s:design OR name_s:build)') + end + + it 'escapes quotes and backslashes through both levels of nesting' do + value = %q(say "hi" \ bye) + session.search(Project) { with_child(:milestones) { with :name, value } } + + # Undo the escaping the way Solr's parsers will: the _query_ phrase, then the local param + unescape = ->(string) { string.gsub(/\\(.)/, '\1') } + local_params = unescape.(last_fq.last[/\A_query_:"(.*)"\z/, 1]) + child_query = unescape.(local_params[/ v="(.*)"\}\z/, 1]) + + expect(child_query).to eq(%Q((_sunspot_nested_path_s:"Project.milestones" AND name_s:#{Sunspot::Util.escape(value)}))) + end + + it 'raises for an association that was never declared' do + expect { session.search(Project) { with_child(:tasks) {} } }.to raise_error(Sunspot::UnrecognizedFieldError) + end + + it 'resolves the association across a multi-class search' do + session.search(Project, Post) { with_child(:milestones) { with :name, 'design' } } + expect(last_fq.last).to include('Project.milestones') + end + end + + describe 'removal' do + it 'removes the whole block when removing a parent' do + project = Project.new + session.remove(project) + expect(connection).to have_delete("Project #{project.id}") + expect(connection).to have_delete_by_query(%Q(_root_:("Project\\ #{project.id}"))) + end + + it 'splits the block deletes to stay under the boolean clause limit' do + session.remove_by_id(Project, (1..1100).to_a) + expect(connection.deletes_by_query.length).to eq(3) + end + + it 'removes the whole block when removing by id' do + session.remove_by_id(Project, 1, 2) + expect(connection).to have_delete('Project 1', 'Project 2') + expect(connection).to have_delete_by_query('_root_:("Project\\ 1" OR "Project\\ 2")') + end + + it 'removes the parents and their children in one query when removing a class' do + session.remove_all(Project) + expect(connection.deletes_by_query).to eq([ + %q{(type:Project) OR _query_:"{!child of=\"*:* -_sunspot_nested_path_s:[* TO *]\" v=\"type:Project\"}"} + ]) + end + + it 'removes the parents and their children in one query when removing by scope' do + session.remove(Project) { with :status, 'archived' } + expect(connection.deletes_by_query).to eq([ + %q{((type:Project AND status_s:archived)) OR _query_:"{!child of=\"*:* -_sunspot_nested_path_s:[* TO *]\" v=\"(type:Project AND status_s:archived)\"}"} + ]) + end + + it "removes a nested subclass's children when removing a class without nested associations" do + session.remove_all(Asset) + expect(connection.deletes_by_query).to eq([ + %q{(type:Asset) OR _query_:"{!child of=\"*:* -_sunspot_nested_path_s:[* TO *]\" v=\"type:Asset\"}"} + ]) + end + + it 'removes a class with no nested associations anywhere beneath it by its plain query' do + session.remove_all(Post) + session.remove(Post) { with :title, 'monkeys' } + expect(connection.deletes_by_query).to eq(['type:Post', '(type:Post AND title_ss:monkeys)']) + end + + it 'removes a class without nested associations when another registered class no longer resolves' do + Object.const_set(:GoneThing, Class.new(MockRecord)) + Sunspot.setup(GoneThing) { string :name } + Object.send(:remove_const, :GoneThing) + + session.remove_all(Post) + session.remove(Post) { with :title, 'monkeys' } + expect(connection.deletes_by_query).to eq(['type:Post', '(type:Post AND title_ss:monkeys)']) + end + + it 'sends no block delete when removing a record of a class without nested associations' do + session.remove(Post.new) + expect(connection.deletes_by_query).to be_empty + end + end +end diff --git a/sunspot/spec/integration/nested_documents_spec.rb b/sunspot/spec/integration/nested_documents_spec.rb new file mode 100644 index 000000000..f463f2683 --- /dev/null +++ b/sunspot/spec/integration/nested_documents_spec.rb @@ -0,0 +1,272 @@ +require File.expand_path('../spec_helper', File.dirname(__FILE__)) + +# RSolr 1.x has no RSolr::Document, so it can't send child documents +describe 'nested documents', :if => defined?(RSolr::Document::CHILD_DOCUMENT_KEY) do + def milestone(name, started_at, attrs = {}) + Milestone.new({ :name => name, :started_at => started_at }.merge(attrs)) + end + + def search(&block) + Sunspot.search(Project, &block).results + end + + # Raw count, straight from Solr, so leftover children can't hide behind Sunspot's type filter + def solr_count(query) + Sunspot.session.send(:connection).get('select', :params => { :q => query, :rows => 0 })['response']['numFound'] + end + + let(:q1) { Time.utc(2026, 1, 1)...Time.utc(2026, 4, 1) } + + # Solr before 8 replaces a document with children by _root_ and one without by id + def skip_before_solr_8 + version = Sunspot.session.send(:connection).get('admin/system')['lucene']['solr-spec-version'] + skip "Solr #{version} doesn't replace a block whose children went from some to none or none to some" if version.to_i < 8 + end + + before :each do + Sunspot.remove_all! + + @designed_in_q1 = Project.new(:name => 'designed in q1', :status => 'active', :milestones => [ + milestone('design', Time.utc(2026, 2, 1), :due_at => Time.utc(2026, 1, 15), :finished_at => Time.utc(2026, 2, 20), :owner_names => %w(ana)) + ]) + # A design milestone and a Q1 date, but on different milestones + @designed_earlier = Project.new(:name => 'designed earlier', :status => 'active', :milestones => [ + milestone('design', Time.utc(2025, 6, 1), :due_at => Time.utc(2025, 7, 1), :finished_at => Time.utc(2025, 6, 20), :owner_names => %w(ben)), + milestone('build', Time.utc(2026, 2, 1), :owner_names => %w(ana)) + ]) + @without_milestones = Project.new(:name => 'no milestones', :status => 'archived') + + # A document of another class between the blocks, sharing a field name with the children + @memo = Memo.new(:name => 'design') + + Sunspot.index!(@designed_in_q1, @memo, @designed_earlier, @without_milestones) + end + + describe 'with_child' do + it 'requires one child to meet every condition' do + q1 = self.q1 + expect(search { with_child(:milestones) { with :name, 'design'; with(:started_at, q1) } }).to eq([@designed_in_q1]) + end + + it 'matches any child with no conditions' do + expect(search { with_child(:milestones) }).to match_array([@designed_in_q1, @designed_earlier]) + end + + it 'filters on a field computed from the child' do + expect(search { with_child(:milestones) { with(:days_late).greater_than(0) } }).to eq([@designed_in_q1]) + end + + it 'matches a value of a multivalued child field' do + expect(search { with_child(:milestones) { with :owner_names, 'ben' } }).to eq([@designed_earlier]) + end + + it 'matches a child by a negated condition' do + expect(search { with_child(:milestones) { without :name, 'design' } }).to eq([@designed_earlier]) + end + + it 'matches a child by several negated conditions' do + expect(search { with_child(:milestones) { without :name, 'design'; without :name, 'launch' } }).to eq([@designed_earlier]) + end + + it 'supports alternatives inside one child' do + q1 = self.q1 + results = search do + with_child :milestones do + any_of do + all_of { with :name, 'design'; with(:started_at, q1) } + with :owner_names, 'ben' + end + end + end + expect(results).to match_array([@designed_in_q1, @designed_earlier]) + end + + it 'combines a child condition with a parent condition in any_of' do + results = search do + any_of do + with_child(:milestones) { with :owner_names, 'ben' } + with :status, 'archived' + end + end + expect(results).to match_array([@designed_earlier, @without_milestones]) + end + + it 'combines with parent restrictions' do + expect(search { with :status, 'active'; with_child(:milestones) { with :owner_names, 'ana' } }).to match_array([@designed_in_q1, @designed_earlier]) + expect(search { with :status, 'archived'; with_child(:milestones) }).to eq([]) + end + + it 'finds values that need escaping' do + tricky = %q(say "hi" \ bye) + odd = Project.new(:milestones => [milestone(tricky, Time.utc(2026, 1, 1))]) + Sunspot.index!(odd) + expect(search { with_child(:milestones) { with :name, tricky } }).to eq([odd]) + end + end + + describe 'without_child' do + it 'matches parents with no child meeting the conditions, including parents with no children' do + q1 = self.q1 + expect(search { without_child(:milestones) { with :name, 'design'; with(:started_at, q1) } }).to match_array([@designed_earlier, @without_milestones]) + end + end + + describe 'searching the parents alone' do + it 'returns parents, never children' do + expect(search {}).to match_array([@designed_in_q1, @designed_earlier, @without_milestones]) + end + + it 'leaves other classes alone' do + expect(Sunspot.search(Memo).results).to eq([@memo]) + end + end + + describe 'reindexing' do + it 'replaces the whole block' do + @designed_earlier.milestones = [@designed_earlier.milestones.last] + Sunspot.index!(@designed_earlier) + + expect(solr_count(%Q(_root_:"Project #{@designed_earlier.id}"))).to eq(2) + expect(solr_count('_sunspot_nested_path_s:[* TO *]')).to eq(2) + expect(search { with_child(:milestones) { with :owner_names, 'ben' } }).to eq([]) + end + + it 'removes every child when a parent is reindexed with none' do + skip_before_solr_8 + @designed_earlier.milestones = [] + Sunspot.index!(@designed_earlier) + + expect(solr_count(%Q(_root_:"Project #{@designed_earlier.id}" AND _sunspot_nested_path_s:[* TO *]))).to eq(0) + expect(solr_count(%Q(id:"Project #{@designed_earlier.id}"))).to eq(1) + end + + it 'replaces a parent that had no children when it is reindexed with some' do + skip_before_solr_8 + @without_milestones.milestones = [milestone('launch', Time.utc(2026, 3, 1))] + Sunspot.index!(@without_milestones) + + expect(solr_count(%Q(id:"Project #{@without_milestones.id}"))).to eq(1) + expect(Sunspot.search(Project).hits.length).to eq(3) + expect(search { with_child(:milestones) { with :name, 'launch' } }).to eq([@without_milestones]) + end + end + + describe 'removal' do + def children + solr_count('_sunspot_nested_path_s:[* TO *]') + end + + it 'removes the children with their parent' do + Sunspot.remove!(@designed_earlier) + expect(children).to eq(1) + expect(search { with_child(:milestones) }).to eq([@designed_in_q1]) + end + + it 'removes the children when removing by id' do + Sunspot.remove_by_id!(Project, @designed_earlier.id) + expect(children).to eq(1) + end + + it 'removes the children when removing by scope' do + Sunspot.remove!(Project) { with :name, 'designed earlier' } + expect(children).to eq(1) + expect(search {}).to match_array([@designed_in_q1, @without_milestones]) + end + + it 'removes the parents a child condition selects, with their children' do + Sunspot.remove!(Project) { with_child(:milestones) { with :owner_names, 'ben' } } + + expect(solr_count(%Q(_root_:"Project #{@designed_earlier.id}"))).to eq(0) + expect(children).to eq(1) + expect(search {}).to match_array([@designed_in_q1, @without_milestones]) + end + + it 'removes the children of a subclass that has no setup of its own' do + Sunspot.index!(Spinoff.new(:name => 'spun off', :milestones => [milestone('design', Time.utc(2026, 2, 1))])) + Sunspot.remove_all!(Spinoff) + expect(solr_count('id:Spinoff*')).to eq(0) + + Sunspot.index!(Spinoff.new(:name => 'spun off', :milestones => [milestone('design', Time.utc(2026, 2, 1))])) + Sunspot.remove!(Spinoff) { with :name, 'spun off' } + expect(solr_count('id:Spinoff*')).to eq(0) + end + + it 'removes every child when removing the class, and nothing else' do + Sunspot.remove_all!(Project) + expect(children).to eq(0) + expect(solr_count('*:*')).to eq(1) + expect(Sunspot.search(Memo).results).to eq([@memo]) + end + end + + describe 'an association read through another method' do + it 'indexes and finds the children the :using method returns' do + plan = Plan.new(:name => 'plan', :milestones => [milestone('design', Time.utc(2026, 2, 1))]) + Sunspot.index!(plan) + + expect(Sunspot.search(Plan) { with_child(:steps) { with :name, 'design' } }.results).to eq([plan]) + expect(solr_count('_sunspot_nested_path_s:"Plan.steps"')).to eq(1) + end + end + + describe 'other classes with the same association' do + let(:t) { Time.utc(2026, 2, 1) } + + it 'finds a subclass that declares the association again through a search of its superclass' do + sub_project = SubProject.new(:name => 'sub', :milestones => [milestone('review', t)]) + Sunspot.index!(sub_project) + + expect(search { with_child(:milestones) { with :name, 'review' } }).to eq([sub_project]) + end + + it 'matches children of every searched class that declares the association' do + program = Program.new(:name => 'program', :milestones => [milestone('design', t)]) + Sunspot.index!(program) + + [[Project, Program], [Program, Project]].each do |types| + results = Sunspot.search(*types) { with_child(:milestones) { with :name, 'design' } }.results + expect(results).to match_array([@designed_in_q1, @designed_earlier, program]) + end + end + + it 'resolves a child field declared by any searched class, whatever their order' do + gadget = Gadget.new(:name => 'gadget', :milestones => [milestone('design', Time.utc(2026, 2, 1))]) + Sunspot.index!(gadget) + + [[Project, Gadget], [Gadget, Project]].each do |types| + expect(Sunspot.search(*types) { with_child(:milestones) { with :only_here, 'yes' } }.results).to eq([gadget]) + expect(Sunspot.search(*types) { with_child(:milestones) { with :name, 'design' } }.results).to match_array([@designed_in_q1, @designed_earlier]) + end + end + + it 'searches text fields on the children of several classes' do + gadget = Gadget.new(:name => 'gadget', :milestones => [milestone('design', Time.utc(2026, 2, 1))]) + Sunspot.index!(gadget) + + expect(Sunspot.search(Project, Gadget) { with_child(:milestones) { text_fields { with :notes, 'gadget notes' } } }.results).to eq([gadget]) + end + + it "resolves a superclass's child fields from its own setup when a subclass declares the association again" do + crate = Crate.new(:name => 'crate', :items => [OpenStruct.new(:at => Time.utc(2026, 2, 1), :body => 'design', :attrs => { :color => 'red' })]) + Sunspot.index!(crate) + + expect(Sunspot.search(Crate) { with_child(:items) { with(:at).greater_than(Time.utc(2026, 1, 1)) } }.results).to eq([crate]) + expect(Sunspot.search(Crate) { with_child(:items) { text_fields { with :body, 'design' } } }.results).to eq([crate]) + expect(Sunspot.search(Crate) { with_child(:items) { dynamic(:attrs) { with :color, 'red' } } }.results).to eq([crate]) + end + + it 'rejects a child field the searched classes declare differently' do + expect { Sunspot.search(Project, Gadget) { with_child(:milestones) { with(:started_at).greater_than(0) } } }.to raise_error(Sunspot::UnrecognizedFieldError) + end + + it 'removes the children of a nested subclass when removing through its superclass' do + Sunspot.index!(Vehicle.new(:name => 'van', :parts => [milestone('wheel', t)])) + Sunspot.remove_all!(Asset) + expect(solr_count('_sunspot_nested_path_s:"Vehicle.parts"')).to eq(0) + + Sunspot.index!(Vehicle.new(:name => 'van', :parts => [milestone('wheel', t)])) + Sunspot.remove!(Asset) { with :name, 'van' } + expect(solr_count('_sunspot_nested_path_s:"Vehicle.parts"')).to eq(0) + end + end +end diff --git a/sunspot/spec/mocks/project.rb b/sunspot/spec/mocks/project.rb new file mode 100644 index 000000000..5d2c4e4c6 --- /dev/null +++ b/sunspot/spec/mocks/project.rb @@ -0,0 +1,164 @@ +class Milestone < MockRecord + attr_accessor :name, :started_at, :finished_at, :due_at, :owner_names +end + +class Project < MockRecord + attr_accessor :name, :status + attr_writer :milestones, :reviews + + def milestones + @milestones ||= [] + end + + # Children that have no adapter of their own + def reviews + @reviews ||= [] + end +end + +Sunspot.setup(Project) do + string :name + string :status + + nested :milestones do + string :name + time :started_at + time :finished_at + time :due_at + string :owner_names, :multiple => true + integer(:days_late) { finished_at && due_at && finished_at > due_at ? ((finished_at - due_at) / 86_400).to_i : 0 } + end + + nested :reviews do + string :verdict + end +end + +# Another class sharing a field name with the children +class Memo < MockRecord + attr_accessor :name +end + +Sunspot.setup(Memo) do + string :name +end + +# A subclass that declares the same association again +class SubProject < Project +end + +Sunspot.setup(SubProject) do + nested :milestones do + string :name + end +end + +# An unrelated class with an association of the same name +class Program < MockRecord + attr_accessor :name + attr_writer :milestones + + def milestones + @milestones ||= [] + end +end + +Sunspot.setup(Program) do + string :name + + nested :milestones do + string :name + end +end + +# A superclass with no nested associations, and a subclass that has one +class Asset < MockRecord + attr_accessor :name +end + +Sunspot.setup(Asset) do + string :name +end + +class Vehicle < Asset + attr_writer :parts + + def parts + @parts ||= [] + end +end + +Sunspot.setup(Vehicle) do + nested :parts do + string :name + end +end + +# A subclass with no setup of its own, which inherits Project's +class Spinoff < Project +end + +# An unrelated class whose milestones declare a field Project's don't, and +# one Project's declare with another type +class Gadget < MockRecord + attr_accessor :name + attr_writer :milestones + + def milestones + @milestones ||= [] + end +end + +Sunspot.setup(Gadget) do + nested :milestones do + string(:only_here) { 'yes' } + integer(:started_at) { 1 } + text(:notes) { 'gadget notes' } + end +end + +# Child fields of every kind, and a subclass that declares the association +# again with one of them typed differently +class Crate < MockRecord + attr_accessor :name + attr_writer :items + + def items + @items ||= [] + end +end + +class SubCrate < Crate +end + +Sunspot.setup(Crate) do + string :name + + nested :items do + time :at + text :body + dynamic_string :attrs + end +end + +Sunspot.setup(SubCrate) do + nested :items do + string(:at) { 'x' } + end +end + +# Children read through a method named differently from the association +class Plan < MockRecord + attr_accessor :name + attr_writer :milestones + + def milestones + @milestones ||= [] + end +end + +Sunspot.setup(Plan) do + nested :steps, :using => :milestones do + string :name + end +end diff --git a/sunspot_solr/solr/solr/configsets/sunspot/conf/schema.xml b/sunspot_solr/solr/solr/configsets/sunspot/conf/schema.xml index 9777c1146..5f80559e3 100644 --- a/sunspot_solr/solr/solr/configsets/sunspot/conf/schema.xml +++ b/sunspot_solr/solr/solr/configsets/sunspot/conf/schema.xml @@ -129,6 +129,9 @@ + + +