From cc18f7bbe4e3c78381146ac155b0d75dc3430bef Mon Sep 17 00:00:00 2001 From: Sampo Kuokkanen Date: Wed, 9 Sep 2026 21:53:20 +0900 Subject: [PATCH 01/11] Pin json below 3 for the sunspot_rails suite ActiveSupport passes quirks_mode to JSON.generate and JSON.parse. json 3 made unknown options an ArgumentError, so Sunspot.remove_all! in spec_helper blows up and every sunspot_rails spec fails with it. Rails 8 dropped quirks_mode, but the Appraisals file stops at Rails 7.0, so nothing in the matrix can run on json 3. Pinning it as a development dependency, next to the psych pin that is already there for the same kind of reason. --- sunspot_rails/sunspot_rails.gemspec | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sunspot_rails/sunspot_rails.gemspec b/sunspot_rails/sunspot_rails.gemspec index 42ecec647..677ac8813 100644 --- a/sunspot_rails/sunspot_rails.gemspec +++ b/sunspot_rails/sunspot_rails.gemspec @@ -39,6 +39,9 @@ Gem::Specification.new do |s| s.add_development_dependency 'bundler', '>= 1.3.0', '< 2.0' if RUBY_VERSION <= '2.0.0' s.add_development_dependency 'nokogiri', '< 1.7' if RUBY_VERSION <= '2.0.0' s.add_development_dependency 'rake', '~> 13.2' + # ActiveSupport passes quirks_mode to JSON.generate/parse and json 3 rejects it. + # Rails 8 dropped the option; nothing in the Rails matrix is that new. + s.add_development_dependency 'json', '< 3' # Ruby 2.5 compatibility if RUBY_VERSION < '2.6' From 9e17f2c79cfe72a4a3bc414be94941172fa5f36d Mon Sep 17 00:00:00 2001 From: Nicholas Jakobsen Date: Mon, 19 Oct 2026 01:40:09 -0700 Subject: [PATCH 02/11] fix: Require a join's type clause, so a join matches only its target type on Solr 7 and later MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: `Sunspot::Query::Join#to_subquery` built `_query_:"{!field f=type}Photo"+_query_:"{!edismax …}keywords"`, marking only the keywords clause required. Solr 5's schema `defaultOperator="AND"` made the type clause required too, but Solr 7 removed that setting and the default operator is OR, so the type clause became optional. A join then matched a container through a document of any type with matching keywords: on Solr 9.10.1 `spec/integration/join_spec.rb` finds a `PhotoContainer` by a `Picture`'s description. The type clause is now prefixed with `+` as well. Both join specs pass on Solr 6.6.6 and 9.10.1. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TAYgm5SknHTiRNTgGA6LBY --- sunspot/lib/sunspot/query/join.rb | 2 +- sunspot/spec/api/query/fulltext_examples.rb | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sunspot/lib/sunspot/query/join.rb b/sunspot/lib/sunspot/query/join.rb index e92cfe4ad..00cb0c97b 100644 --- a/sunspot/lib/sunspot/query/join.rb +++ b/sunspot/lib/sunspot/query/join.rb @@ -45,7 +45,7 @@ def to_subquery { :q => "_query_:\"{!join from=#{@from} to=#{@to} v=$#{q_name}}\"", - q_name => "_query_:\"{!field f=type}#{@target.name}\"+_query_:\"{!edismax #{options}}#{keywords}\"" + q_name => "+_query_:\"{!field f=type}#{@target.name}\"+_query_:\"{!edismax #{options}}#{keywords}\"" } end diff --git a/sunspot/spec/api/query/fulltext_examples.rb b/sunspot/spec/api/query/fulltext_examples.rb index e99d88345..2e3dcb62d 100644 --- a/sunspot/spec/api/query/fulltext_examples.rb +++ b/sunspot/spec/api/query/fulltext_examples.rb @@ -420,7 +420,7 @@ q_name = "qPhoto#{obj_id}" expect(connection.searches.last[:q]).to eq "(_query_:\"{!join from=photo_container_id_i to=id_i v=$#{q_name}}\" OR _query_:\"{!edismax qf='description_text^1.2'}keyword2\")" - expect(connection.searches.last[q_name]).to eq "_query_:\"{!field f=type}Photo\"+_query_:\"{!edismax qf='caption_text'}keyword1\"" + expect(connection.searches.last[q_name]).to eq "+_query_:\"{!field f=type}Photo\"+_query_:\"{!edismax qf='caption_text'}keyword1\"" end it "should be able to resolve name conflicts with the :prefix option" do @@ -435,7 +435,7 @@ q_name = "qPhoto#{obj_id}" expect(connection.searches.last[:q]).to eq "(_query_:\"{!edismax qf='description_text^1.2'}keyword1\" OR _query_:\"{!join from=photo_container_id_i to=id_i v=$#{q_name}}\")" - expect(connection.searches.last[q_name]).to eq "_query_:\"{!field f=type}Photo\"+_query_:\"{!edismax qf='description_text'}keyword2\"" + expect(connection.searches.last[q_name]).to eq "+_query_:\"{!field f=type}Photo\"+_query_:\"{!edismax qf='description_text'}keyword2\"" end it "should recognize fields when adding from DSL, e.g. when calling boost_fields" do @@ -451,7 +451,7 @@ q_name = "qPhoto#{obj_id}" expect(connection.searches.last[:q]).to eq "(_query_:\"{!edismax qf='description_text^1.5'}keyword1\" OR _query_:\"{!join from=photo_container_id_i to=id_i v=$#{q_name}}\")" - expect(connection.searches.last[q_name]).to eq "_query_:\"{!field f=type}Photo\"+_query_:\"{!edismax qf='description_text^1.3'}keyword1\"" + expect(connection.searches.last[q_name]).to eq "+_query_:\"{!field f=type}Photo\"+_query_:\"{!edismax qf='description_text^1.3'}keyword1\"" end private From c6978d9775e13a215df87c44b526a6f10bb25729 Mon Sep 17 00:00:00 2001 From: Nicholas Jakobsen Date: Mon, 19 Oct 2026 01:40:09 -0700 Subject: [PATCH 03/11] ci: Run the `sunspot` and `sunspot_rails` specs on Solr 9.10.1 Issue: CI ran the specs on the Solr 5.3.1 that `sunspot_solr` bundles, while the fork's users run Solr 9.10.1 in production. Behaviour that only newer Solr has, such as the `{!bool}` parser, couldn't be tested, and behaviour newer Solr changed went unnoticed. With `SOLR_IMAGE` set, `ci/sunspot_test_script.sh` runs that Docker image with the configset in `ci/solr9`, and the workflow sets it to `solr:9.10.1` for the `sunspot` and `sunspot_rails` jobs. `sunspot_solr`'s own specs still test the Solr it bundles. `ci/solr9` is `sunspot_solr`'s configset ported to Solr 9: point fields with docValues in place of trie fields, `LatLonPointSpatialField` in place of `LatLonType`, and the schema elements Solr 9 removed (`StandardFilterFactory`, `defaultSearchField`, `solrQueryParser`) taken out. In `solrconfig.xml`, `luceneMatchVersion` is 9.0.0, and `/select` defaults to `mm=100%`, which edismax defaulted to before Solr 7, and to the original highlighter, which Sunspot's `hl.simple.*` parameters are for. Four integration specs depend on behaviour newer Solr changed, and `skip_from_solr` skips them there: index-time document boosts (removed in Solr 7), `facet.date` (removed by Solr 6), relative scores of term and range boost queries (BM25, the default similarity from `luceneMatchVersion` 6; the spec passes under `ClassicSimilarity`), and one ranking of boosted fulltext, which fails on Solr 6.6.6 and 9.10.1 under either similarity for a reason not yet found. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TAYgm5SknHTiRNTgGA6LBY --- .github/workflows/ci.yml | 2 + ci/solr9/conf/_rest_managed.json | 1 + ci/solr9/conf/admin-extra.html | 31 + ci/solr9/conf/currency.xml | 67 ++ ci/solr9/conf/elevate.xml | 36 + ci/solr9/conf/lang/stopwords_en.txt | 54 ++ ci/solr9/conf/mapping-ISOLatin1Accent.txt | 246 +++++++ ci/solr9/conf/protwords.txt | 21 + ci/solr9/conf/schema.xml | 277 ++++++++ ci/solr9/conf/scripts.conf | 24 + ci/solr9/conf/solrconfig.xml | 633 ++++++++++++++++++ ci/solr9/conf/spellings.txt | 2 + ci/solr9/conf/synonyms.txt | 29 + ci/sunspot_test_script.sh | 26 + sunspot/spec/helpers/integration_helper.rb | 6 + sunspot/spec/integration/faceting_spec.rb | 1 + .../spec/integration/keyword_search_spec.rb | 2 + .../spec/integration/scoped_search_spec.rb | 1 + 18 files changed, 1459 insertions(+) create mode 100644 ci/solr9/conf/_rest_managed.json create mode 100644 ci/solr9/conf/admin-extra.html create mode 100644 ci/solr9/conf/currency.xml create mode 100644 ci/solr9/conf/elevate.xml create mode 100644 ci/solr9/conf/lang/stopwords_en.txt create mode 100644 ci/solr9/conf/mapping-ISOLatin1Accent.txt create mode 100644 ci/solr9/conf/protwords.txt create mode 100644 ci/solr9/conf/schema.xml create mode 100644 ci/solr9/conf/scripts.conf create mode 100644 ci/solr9/conf/solrconfig.xml create mode 100644 ci/solr9/conf/spellings.txt create mode 100644 ci/solr9/conf/synonyms.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5d70ee55..257022b13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,5 @@ jobs: env: GEM: ${{ matrix.gem }} UPDATE_FORMAT: ${{ matrix.update-format }} + # The Solr Culture Code runs in production. sunspot_solr's own specs test the Solr it bundles. + SOLR_IMAGE: ${{ matrix.gem != 'sunspot_solr' && 'solr:9.10.1' || '' }} diff --git a/ci/solr9/conf/_rest_managed.json b/ci/solr9/conf/_rest_managed.json new file mode 100644 index 000000000..6a4aec39e --- /dev/null +++ b/ci/solr9/conf/_rest_managed.json @@ -0,0 +1 @@ +{"initArgs":{},"managedList":[]} diff --git a/ci/solr9/conf/admin-extra.html b/ci/solr9/conf/admin-extra.html new file mode 100644 index 000000000..aa739da86 --- /dev/null +++ b/ci/solr9/conf/admin-extra.html @@ -0,0 +1,31 @@ + + + diff --git a/ci/solr9/conf/currency.xml b/ci/solr9/conf/currency.xml new file mode 100644 index 000000000..3a9c58afe --- /dev/null +++ b/ci/solr9/conf/currency.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ci/solr9/conf/elevate.xml b/ci/solr9/conf/elevate.xml new file mode 100644 index 000000000..7630ebe20 --- /dev/null +++ b/ci/solr9/conf/elevate.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + diff --git a/ci/solr9/conf/lang/stopwords_en.txt b/ci/solr9/conf/lang/stopwords_en.txt new file mode 100644 index 000000000..2c164c0b2 --- /dev/null +++ b/ci/solr9/conf/lang/stopwords_en.txt @@ -0,0 +1,54 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# a couple of test stopwords to test that the words are really being +# configured from this file: +stopworda +stopwordb + +# Standard english stop words taken from Lucene's StopAnalyzer +a +an +and +are +as +at +be +but +by +for +if +in +into +is +it +no +not +of +on +or +such +that +the +their +then +there +these +they +this +to +was +will +with diff --git a/ci/solr9/conf/mapping-ISOLatin1Accent.txt b/ci/solr9/conf/mapping-ISOLatin1Accent.txt new file mode 100644 index 000000000..ede774258 --- /dev/null +++ b/ci/solr9/conf/mapping-ISOLatin1Accent.txt @@ -0,0 +1,246 @@ +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Syntax: +# "source" => "target" +# "source".length() > 0 (source cannot be empty.) +# "target".length() >= 0 (target can be empty.) + +# example: +# "À" => "A" +# "\u00C0" => "A" +# "\u00C0" => "\u0041" +# "ß" => "ss" +# "\t" => " " +# "\n" => "" + +# À => A +"\u00C0" => "A" + +# Á => A +"\u00C1" => "A" + +#  => A +"\u00C2" => "A" + +# à => A +"\u00C3" => "A" + +# Ä => A +"\u00C4" => "A" + +# Å => A +"\u00C5" => "A" + +# Æ => AE +"\u00C6" => "AE" + +# Ç => C +"\u00C7" => "C" + +# È => E +"\u00C8" => "E" + +# É => E +"\u00C9" => "E" + +# Ê => E +"\u00CA" => "E" + +# Ë => E +"\u00CB" => "E" + +# Ì => I +"\u00CC" => "I" + +# Í => I +"\u00CD" => "I" + +# Î => I +"\u00CE" => "I" + +# Ï => I +"\u00CF" => "I" + +# IJ => IJ +"\u0132" => "IJ" + +# Ð => D +"\u00D0" => "D" + +# Ñ => N +"\u00D1" => "N" + +# Ò => O +"\u00D2" => "O" + +# Ó => O +"\u00D3" => "O" + +# Ô => O +"\u00D4" => "O" + +# Õ => O +"\u00D5" => "O" + +# Ö => O +"\u00D6" => "O" + +# Ø => O +"\u00D8" => "O" + +# Œ => OE +"\u0152" => "OE" + +# Þ +"\u00DE" => "TH" + +# Ù => U +"\u00D9" => "U" + +# Ú => U +"\u00DA" => "U" + +# Û => U +"\u00DB" => "U" + +# Ü => U +"\u00DC" => "U" + +# Ý => Y +"\u00DD" => "Y" + +# Ÿ => Y +"\u0178" => "Y" + +# à => a +"\u00E0" => "a" + +# á => a +"\u00E1" => "a" + +# â => a +"\u00E2" => "a" + +# ã => a +"\u00E3" => "a" + +# ä => a +"\u00E4" => "a" + +# å => a +"\u00E5" => "a" + +# æ => ae +"\u00E6" => "ae" + +# ç => c +"\u00E7" => "c" + +# è => e +"\u00E8" => "e" + +# é => e +"\u00E9" => "e" + +# ê => e +"\u00EA" => "e" + +# ë => e +"\u00EB" => "e" + +# ì => i +"\u00EC" => "i" + +# í => i +"\u00ED" => "i" + +# î => i +"\u00EE" => "i" + +# ï => i +"\u00EF" => "i" + +# ij => ij +"\u0133" => "ij" + +# ð => d +"\u00F0" => "d" + +# ñ => n +"\u00F1" => "n" + +# ò => o +"\u00F2" => "o" + +# ó => o +"\u00F3" => "o" + +# ô => o +"\u00F4" => "o" + +# õ => o +"\u00F5" => "o" + +# ö => o +"\u00F6" => "o" + +# ø => o +"\u00F8" => "o" + +# œ => oe +"\u0153" => "oe" + +# ß => ss +"\u00DF" => "ss" + +# þ => th +"\u00FE" => "th" + +# ù => u +"\u00F9" => "u" + +# ú => u +"\u00FA" => "u" + +# û => u +"\u00FB" => "u" + +# ü => u +"\u00FC" => "u" + +# ý => y +"\u00FD" => "y" + +# ÿ => y +"\u00FF" => "y" + +# ff => ff +"\uFB00" => "ff" + +# fi => fi +"\uFB01" => "fi" + +# fl => fl +"\uFB02" => "fl" + +# ffi => ffi +"\uFB03" => "ffi" + +# ffl => ffl +"\uFB04" => "ffl" + +# ſt => ft +"\uFB05" => "ft" + +# st => st +"\uFB06" => "st" diff --git a/ci/solr9/conf/protwords.txt b/ci/solr9/conf/protwords.txt new file mode 100644 index 000000000..1dfc0abec --- /dev/null +++ b/ci/solr9/conf/protwords.txt @@ -0,0 +1,21 @@ +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +#----------------------------------------------------------------------- +# Use a protected word file to protect against the stemmer reducing two +# unrelated words to the same base word. + +# Some non-words that normally won't be encountered, +# just to test that they won't be stemmed. +dontstems +zwhacky + diff --git a/ci/solr9/conf/schema.xml b/ci/solr9/conf/schema.xml new file mode 100644 index 000000000..82c560dd1 --- /dev/null +++ b/ci/solr9/conf/schema.xml @@ -0,0 +1,277 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id + + + + + + + + diff --git a/ci/solr9/conf/scripts.conf b/ci/solr9/conf/scripts.conf new file mode 100644 index 000000000..f58b262ae --- /dev/null +++ b/ci/solr9/conf/scripts.conf @@ -0,0 +1,24 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +user= +solr_hostname=localhost +solr_port=8983 +rsyncd_port=18983 +data_dir= +webapp_name=solr +master_host= +master_data_dir= +master_status_dir= diff --git a/ci/solr9/conf/solrconfig.xml b/ci/solr9/conf/solrconfig.xml new file mode 100644 index 000000000..2463b5d57 --- /dev/null +++ b/ci/solr9/conf/solrconfig.xml @@ -0,0 +1,633 @@ + + + + + + + + + 9.0.0 + + + ${solr.data.dir:} + + + + + + + + + + + + + + + + ${solr.lock.type:native} + + + true + + + + + + + + + + + + + + + + ${solr.ulog.dir:} + + + + + ${solr.autoCommit.maxTime:15000} + false + + + + + ${solr.autoSoftCommit.maxTime:-1} + + + + + + + + 1024 + + + + + + + + + + + + + + + + + + true + + + 20 + + + 200 + + + false + + + 2 + + + + + + + + + + + + + + + + + + + original + 100% + default + true + true + + + spellcheck + + + + + + + explicit + json + true + text + + + spellcheck + + + + + + + {!xport} + xsort + false + + + + query + + + + + + + text + + + + + + + + + + + + + + explicit + true + + + + + + textSpell + + + + default + + textSpell + true + + + + example + title_text + true + solr.DirectSolrSpellChecker + + internal + + 0.5 + + 2 + + 1 + + 5 + + 4 + + 0.01 + + + + + + wordbreak + solr.WordBreakSolrSpellChecker + name + true + true + 10 + + --> + + + + + + + + + + + true + false + + + terms + + + + + + 1 + 2 + + + + + + *:* + + + diff --git a/ci/solr9/conf/spellings.txt b/ci/solr9/conf/spellings.txt new file mode 100644 index 000000000..d7ede6f56 --- /dev/null +++ b/ci/solr9/conf/spellings.txt @@ -0,0 +1,2 @@ +pizza +history \ No newline at end of file diff --git a/ci/solr9/conf/synonyms.txt b/ci/solr9/conf/synonyms.txt new file mode 100644 index 000000000..7f7212830 --- /dev/null +++ b/ci/solr9/conf/synonyms.txt @@ -0,0 +1,29 @@ +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +#----------------------------------------------------------------------- +#some test synonym mappings unlikely to appear in real input text +aaafoo => aaabar +bbbfoo => bbbfoo bbbbar +cccfoo => cccbar cccbaz +fooaaa,baraaa,bazaaa + +# Some synonym groups specific to this example +GB,gib,gigabyte,gigabytes +MB,mib,megabyte,megabytes +Television, Televisions, TV, TVs +#notice we use "gib" instead of "GiB" so any WordDelimiterFilter coming +#after us won't split it into two words. + +# Synonym mappings can be used for spelling correction too +pixima => pixma + diff --git a/ci/sunspot_test_script.sh b/ci/sunspot_test_script.sh index 0875d6224..8460192d1 100755 --- a/ci/sunspot_test_script.sh +++ b/ci/sunspot_test_script.sh @@ -9,7 +9,28 @@ solr_responding() { curl -o /dev/null "http://localhost:$SOLR_PORT/solr/admin/ping" > /dev/null 2>&1 } +# With SOLR_IMAGE set, runs that Docker image of Solr with the configset in ci/solr9, in place of the +# Solr that sunspot_solr bundles. +start_solr_container() { + /bin/echo -n "Starting $SOLR_IMAGE on port $SOLR_PORT for Sunspot specs..." + docker run -d --name sunspot-solr -p $SOLR_PORT:8983 $SOLR_IMAGE > /dev/null + until curl -s -o /dev/null "http://localhost:$SOLR_PORT/solr/admin/cores"; do + /bin/echo -n "." + sleep 1 + done + docker exec --user root sunspot-solr mkdir -p /var/solr/data/configsets + docker cp "$SUNSPOT_LIB_HOME/ci/solr9" sunspot-solr:/var/solr/data/configsets/sunspot + docker exec --user root sunspot-solr chown -R solr:solr /var/solr/data/configsets + curl -s -f -X POST "http://localhost:$SOLR_PORT/solr/admin/cores?action=CREATE&name=default&configSet=sunspot" > /dev/null + /bin/echo "done." +} + start_solr_server() { + if [ -n "$SOLR_IMAGE" ]; then + start_solr_container + return + fi + # go to sunspot_solr folder and install dependencies current_path=`pwd` cd ../sunspot_solr @@ -34,6 +55,11 @@ start_solr_server() { } stop_solr_server() { + if [ -n "$SOLR_IMAGE" ]; then + docker rm -f sunspot-solr > /dev/null + return + fi + cd ../sunspot_solr /bin/echo -n "Stopping Solr... " bundle exec sunspot-solr stop -p $SOLR_PORT diff --git a/sunspot/spec/helpers/integration_helper.rb b/sunspot/spec/helpers/integration_helper.rb index c2a9c5cc7..bde797e0d 100644 --- a/sunspot/spec/helpers/integration_helper.rb +++ b/sunspot/spec/helpers/integration_helper.rb @@ -7,6 +7,12 @@ def self.included(base) end end + # Skips the example on Solr `major` and later, for behaviour that version changed + def skip_from_solr(major, reason) + version = Sunspot.session.send(:connection).get('admin/system')['lucene']['solr-spec-version'] + skip "#{reason} (Solr #{version})" if version.to_i >= major + end + def featured_for_posts(method, param, negated = false) with_method = negated ? :without : :with param = date_ranges[param] if param.is_a? String diff --git a/sunspot/spec/integration/faceting_spec.rb b/sunspot/spec/integration/faceting_spec.rb index 299a9a5e2..f58cb7911 100644 --- a/sunspot/spec/integration/faceting_spec.rb +++ b/sunspot/spec/integration/faceting_spec.rb @@ -516,6 +516,7 @@ def self.test_field_type(name, attribute, field, *args) end it 'should return time ranges' do + skip_from_solr 6, 'Solr 6 removed facet.date' time = Time.utc(2009, 7, 8) search = Sunspot.search(Post) do facet :published_at, :time_range => time..(time + 60*60*24*2), :sort => :count diff --git a/sunspot/spec/integration/keyword_search_spec.rb b/sunspot/spec/integration/keyword_search_spec.rb index ac09866aa..305fb7fc2 100644 --- a/sunspot/spec/integration/keyword_search_spec.rb +++ b/sunspot/spec/integration/keyword_search_spec.rb @@ -142,6 +142,7 @@ end it 'should assign a higher score to the higher-boosted document' do + skip_from_solr 7, 'Solr 7 removed index-time document boosts' search = Sunspot.search(Post) { keywords 'test' } expect(search.hits.map { |hit| hit.primary_key }).to eq( @posts.map { |post| post.id.to_s } @@ -217,6 +218,7 @@ end it 'should assign scores in order of multiple boost query match' do + skip_from_solr 7, 'BM25, the default similarity from luceneMatchVersion 6, scores the featured term below the rating range' search = Sunspot.search(Post) do keywords 'rhino' do boost(2.0) { with(:featured, true) } diff --git a/sunspot/spec/integration/scoped_search_spec.rb b/sunspot/spec/integration/scoped_search_spec.rb index 75cd77bbe..e867362d0 100644 --- a/sunspot/spec/integration/scoped_search_spec.rb +++ b/sunspot/spec/integration/scoped_search_spec.rb @@ -594,6 +594,7 @@ def search_ordered_by_random(direction = nil) end it 'should work properly when combined with fulltext' do + skip_from_solr 6, 'Ranks @p1 first on Solr 6.6.6 and 9.10.1, under BM25 and classic similarity alike; cause not yet found' search = Sunspot.search(Post) do fulltext('Post Ipsum') do boost_fields :body => 0.2 From a64cac151852a17a0f1420fb68ba25a65da5c02e Mon Sep 17 00:00:00 2001 From: Nicholas Jakobsen Date: Tue, 20 Oct 2026 01:43:15 -0700 Subject: [PATCH 04/11] fix: Fail the CI run when the Solr container doesn't start or its core can't be created Root cause: `start_solr_container` runs under `set +e` and waited for Solr with an unbounded `until curl` loop. When `docker run` failed, for example on an image pull error or a Docker Hub rate limit, the job spun until the workflow's 60-minute timeout. When copying the configset or creating the core failed, it still printed "done.", and the run showed up only as every spec failing. `docker run` failing now exits the script, the wait gives up after 120 tries and prints the container's log, and a failed `mkdir`, `docker cp`, `chown` or core `CREATE` prints the log and exits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TAYgm5SknHTiRNTgGA6LBY --- ci/sunspot_test_script.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/ci/sunspot_test_script.sh b/ci/sunspot_test_script.sh index 8460192d1..3710ff0ae 100755 --- a/ci/sunspot_test_script.sh +++ b/ci/sunspot_test_script.sh @@ -13,15 +13,23 @@ solr_responding() { # Solr that sunspot_solr bundles. start_solr_container() { /bin/echo -n "Starting $SOLR_IMAGE on port $SOLR_PORT for Sunspot specs..." - docker run -d --name sunspot-solr -p $SOLR_PORT:8983 $SOLR_IMAGE > /dev/null + docker run -d --name sunspot-solr -p $SOLR_PORT:8983 $SOLR_IMAGE > /dev/null || exit 1 + tries=0 until curl -s -o /dev/null "http://localhost:$SOLR_PORT/solr/admin/cores"; do + tries=$((tries + 1)) + if [ $tries -gt 120 ]; then + /bin/echo " Solr didn't start." + docker logs sunspot-solr + exit 1 + fi /bin/echo -n "." sleep 1 done - docker exec --user root sunspot-solr mkdir -p /var/solr/data/configsets - docker cp "$SUNSPOT_LIB_HOME/ci/solr9" sunspot-solr:/var/solr/data/configsets/sunspot - docker exec --user root sunspot-solr chown -R solr:solr /var/solr/data/configsets - curl -s -f -X POST "http://localhost:$SOLR_PORT/solr/admin/cores?action=CREATE&name=default&configSet=sunspot" > /dev/null + docker exec --user root sunspot-solr mkdir -p /var/solr/data/configsets && + docker cp "$SUNSPOT_LIB_HOME/ci/solr9" sunspot-solr:/var/solr/data/configsets/sunspot && + docker exec --user root sunspot-solr chown -R solr:solr /var/solr/data/configsets && + curl -s -S -f -X POST "http://localhost:$SOLR_PORT/solr/admin/cores?action=CREATE&name=default&configSet=sunspot" > /dev/null || + { /bin/echo " Couldn't create the default core."; docker logs sunspot-solr; exit 1; } /bin/echo "done." } From 282d758510fe2567f089b878ca2d06e4c83f63cc Mon Sep 17 00:00:00 2001 From: Sampo Kuokkanen Date: Wed, 21 Oct 2026 09:44:48 +0900 Subject: [PATCH 05/11] Move the json pin into the per-Rails appraisals The quirks_mode incompatibility depends on the Rails version, so pin json next to the other version-specific gems instead of in the gemspec, and correct the comment: 8.0.x still passes quirks_mode; only 7.2.4+ and 8.1+ don't. --- sunspot_rails/Appraisals | 4 ++++ sunspot_rails/sunspot_rails.gemspec | 3 --- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/sunspot_rails/Appraisals b/sunspot_rails/Appraisals index ca3d08520..66f1f291a 100644 --- a/sunspot_rails/Appraisals +++ b/sunspot_rails/Appraisals @@ -11,6 +11,8 @@ if ruby_version < Gem::Version.new('3.0.0') gem 'sqlite3', '~> 1.3.0' gem 'rspec', '~> 3.7' gem 'rspec-rails', '~> 3.7' + # ActiveSupport < 7.2.4 and 8.0.x pass quirks_mode to JSON.generate/parse, which json 3 rejects + gem 'json', '< 3' end end end @@ -26,6 +28,8 @@ if Gem::Version.new('3.0.0') <= ruby_version && ruby_version < Gem::Version.new( gem 'sqlite3', '~> 1.4.0' gem 'rspec', '~> 3' gem 'rspec-rails', '~> 6' + # ActiveSupport < 7.2.4 and 8.0.x pass quirks_mode to JSON.generate/parse, which json 3 rejects + gem 'json', '< 3' end end end diff --git a/sunspot_rails/sunspot_rails.gemspec b/sunspot_rails/sunspot_rails.gemspec index 677ac8813..42ecec647 100644 --- a/sunspot_rails/sunspot_rails.gemspec +++ b/sunspot_rails/sunspot_rails.gemspec @@ -39,9 +39,6 @@ Gem::Specification.new do |s| s.add_development_dependency 'bundler', '>= 1.3.0', '< 2.0' if RUBY_VERSION <= '2.0.0' s.add_development_dependency 'nokogiri', '< 1.7' if RUBY_VERSION <= '2.0.0' s.add_development_dependency 'rake', '~> 13.2' - # ActiveSupport passes quirks_mode to JSON.generate/parse and json 3 rejects it. - # Rails 8 dropped the option; nothing in the Rails matrix is that new. - s.add_development_dependency 'json', '< 3' # Ruby 2.5 compatibility if RUBY_VERSION < '2.6' From 205cb6db46230748a09dde15a3f766c9c910e328 Mon Sep 17 00:00:00 2001 From: Nicholas Jakobsen Date: Tue, 20 Oct 2026 23:42:04 -0700 Subject: [PATCH 06/11] fix: Add an `index_time_boosts` setting, so JSON updates work on Solr 9 Root cause: Sunspot sends a field's `:boost` with its value, and with the JSON update format RSolr writes it as `"title_text":{"boost":2,"value":"pizza"}`. Solr 7 removed index-time boosts, and Solr 9 reads that object as a field named `boost`, rejecting the whole update with `400 unknown field 'boost'`. XML updates carry the boost as an attribute, which Solr 9 ignores, so only the `sunspot, json` CI jobs on Solr 9.10.1 failed: 180 integration examples. `Sunspot.config.solr.index_time_boosts`, and `index_time_boosts` under `solr:` in `sunspot.yml`, defaults to true, which keeps what Sunspot sends unchanged for everyone. Set to false, the indexer removes field and document boosts from every document it sends, child documents included. The integration specs set it from the Solr they run against: false on Solr 7 and later. The README explains the setting, and gains an "Upgrading to Solr 9" section listing what changes for Sunspot. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TAYgm5SknHTiRNTgGA6LBY --- README.md | 15 +++++++++++++++ sunspot/lib/sunspot/configuration.rb | 6 ++++++ sunspot/lib/sunspot/indexer.rb | 13 ++++++++++++- sunspot/lib/sunspot/session.rb | 2 +- sunspot/spec/api/indexer/fulltext_spec.rb | 14 ++++++++++++++ sunspot/spec/api/nested_documents_spec.rb | 6 ++++++ sunspot/spec/helpers/integration_helper.rb | 8 +++++++- sunspot/spec/mocks/project.rb | 2 +- sunspot_rails/lib/sunspot/rails.rb | 2 ++ sunspot_rails/lib/sunspot/rails/configuration.rb | 8 ++++++++ sunspot_rails/spec/configuration_spec.rb | 8 ++++++++ sunspot_rails/spec/rails_app/config/sunspot.yml | 1 + 12 files changed, 81 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 010ed5057..6a2452221 100644 --- a/README.md +++ b/README.md @@ -1507,6 +1507,8 @@ class Post < ActiveRecord::Base end ``` +Solr 7 removed index-time boosts, for fields and documents alike. It ignores them in XML updates, and Solr 9 rejects a JSON update that carries one with `unknown field 'boost'`. On Solr 7 and later, set `index_time_boosts` to false so Sunspot leaves them out, and boost at query time instead: `:default_boost` on the field, or `boost_fields` in the search. See [Upgrading to Solr 9](#upgrading-to-solr-9). + ### Stored Fields Stored fields keep an original (untokenized/unanalyzed) version of their @@ -1699,6 +1701,8 @@ test: log_level: WARNING ``` +On Solr 7 and later, add `index_time_boosts: false` under `solr:`, as [Index-Time Boosts](#index-time-boosts) explains. Without Rails, set `Sunspot.config.solr.index_time_boosts = false`. + You may want to use SSL for production environments with a username and password. For example, set `SOLR_URL` to `https://username:password@production.solr.example.com/solr`. You can examine the value of `Sunspot::Rails.configuration` at runtime. @@ -1743,6 +1747,17 @@ 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`. +### Upgrading to Solr 9 + +Sunspot's specs pass on Solr 9.10.1 with the configset in `ci/solr9`. Coming from the Solr that `sunspot_solr` bundles, these are the changes that affect Sunspot: + +* **The schema.** Solr 9 won't load the bundled schema. `ci/solr9` is a port of it: point fields with docValues in place of trie fields, `LatLonPointSpatialField` in place of `LatLonType`, and `StandardFilterFactory`, `defaultSearchField` and `solrQueryParser` taken out. Use it as a starting point for your core, and reindex everything after switching, since the field types change. +* **Index-time boosts.** Solr 7 removed them, and Solr 9 rejects a JSON update that carries one. Set `index_time_boosts` to false ([Index-Time Boosts](#index-time-boosts)). +* **Matching every keyword.** Since Solr 7, edismax no longer requires every term by default. `ci/solr9/conf/solrconfig.xml` sets `mm=100%` on `/select`, so a search for several keywords still matches only documents that have them all, as it did before. +* **Scores.** BM25 has been the default similarity since `luceneMatchVersion` 6, so relative scores differ from the bundled Solr's, and results with close scores can change order. +* **Time range facets.** `facet :published_at, :time_range => …` sends `facet.date`, which Solr 6 removed. Use a [query facet](#query-facets) with a row for each range instead. +* **Nested documents.** A core needs a `_root_` field for them ([Adding `_root_` to an existing core](#adding-_root_-to-an-existing-core)). + ### 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. diff --git a/sunspot/lib/sunspot/configuration.rb b/sunspot/lib/sunspot/configuration.rb index b9b7166f3..d1ec3bbe7 100644 --- a/sunspot/lib/sunspot/configuration.rb +++ b/sunspot/lib/sunspot/configuration.rb @@ -5,6 +5,11 @@ module Sunspot # Sunspot.config.solr.url:: # The URL at which to connect to Solr # (default: 'http://localhost:8983/solr') + # Sunspot.config.solr.index_time_boosts:: + # Whether to send the field and document boosts declared with +:boost+ + # and +boost+ (default: true). Solr 7 removed index-time boosts, and + # Solr 9 rejects a JSON update that carries one, so set it to false on + # Solr 7 and later. # Sunspot.config.pagination.default_per_page:: # Solr always paginates its results. This sets Sunspot's default result # count per page if it is not explicitly specified in the query. @@ -27,6 +32,7 @@ def build #:nodoc: open_timeout nil proxy nil update_format :xml + index_time_boosts true end master_solr do url nil diff --git a/sunspot/lib/sunspot/indexer.rb b/sunspot/lib/sunspot/indexer.rb index e0952ab1e..58498cf75 100644 --- a/sunspot/lib/sunspot/indexer.rb +++ b/sunspot/lib/sunspot/indexer.rb @@ -9,8 +9,9 @@ module Sunspot # class Indexer #:nodoc: - def initialize(connection) + def initialize(connection, index_time_boosts: true) @connection = connection + @index_time_boosts = index_time_boosts end # @@ -240,9 +241,19 @@ def prepare_atomic_update(clazz, id, updates = {}) end def add_documents(documents) + documents.each { |document| remove_boosts(document) } unless @index_time_boosts @connection.add(documents) end + # Removes the boosts from +document+, its fields and its child documents + def remove_boosts(document) + document.attrs.delete(:boost) + document.fields.each do |field| + field.attrs.delete(:boost) + remove_boosts(field.value) if field.value.respond_to?(:fields) + end + end + def add_batch_documents(documents) if batcher.batching? batcher.concat(documents) diff --git a/sunspot/lib/sunspot/session.rb b/sunspot/lib/sunspot/session.rb index 846691574..7a10b4d40 100644 --- a/sunspot/lib/sunspot/session.rb +++ b/sunspot/lib/sunspot/session.rb @@ -264,7 +264,7 @@ def connection end def indexer - @indexer ||= Indexer.new(connection) + @indexer ||= Indexer.new(connection, index_time_boosts: config.solr.index_time_boosts != false) end def setup_for_types(types) diff --git a/sunspot/spec/api/indexer/fulltext_spec.rb b/sunspot/spec/api/indexer/fulltext_spec.rb index 05c3ea327..62dde3fdf 100644 --- a/sunspot/spec/api/indexer/fulltext_spec.rb +++ b/sunspot/spec/api/indexer/fulltext_spec.rb @@ -40,4 +40,18 @@ session.index(Photo.new) expect(connection.adds.last.first.attrs[:boost]).to eq(0.75) end + + describe 'with index_time_boosts off' do + before { config.solr.index_time_boosts = false } + + it 'sends no field boost' do + session.index(post(:title => 'A Title')) + expect(connection.adds.last.first.field_by_name(:title_text).attrs).not_to have_key(:boost) + end + + it 'sends no document boost' do + session.index(Photo.new) + expect(connection.adds.last.first.attrs).not_to have_key(:boost) + end + end end diff --git a/sunspot/spec/api/nested_documents_spec.rb b/sunspot/spec/api/nested_documents_spec.rb index d06f03adb..aa1f6e6f6 100644 --- a/sunspot/spec/api/nested_documents_spec.rb +++ b/sunspot/spec/api/nested_documents_spec.rb @@ -96,6 +96,12 @@ def children(document) expect(children(connection.adds.last.first)).to be_empty end + it 'sends no child field boost with index_time_boosts off' do + session.config.solr.index_time_boosts = false + session.index(Gadget.new(:milestones => [milestone])) + expect(children(connection.adds.last.first).first.field_by_name(:notes_text).attrs).not_to have_key(:boost) + end + end describe 'indexing with an RSolr that has no child documents', :unless => child_documents do diff --git a/sunspot/spec/helpers/integration_helper.rb b/sunspot/spec/helpers/integration_helper.rb index bde797e0d..1920b1ac1 100644 --- a/sunspot/spec/helpers/integration_helper.rb +++ b/sunspot/spec/helpers/integration_helper.rb @@ -4,12 +4,18 @@ def self.included(base) Sunspot.config.solr.url = ENV['SOLR_URL'] || 'http://localhost:8983/solr/default' Sunspot.config.solr.update_format = ENV['UPDATE_FORMAT'].to_sym if ENV['UPDATE_FORMAT'] Sunspot.reset!(true) + # Solr 7 removed index-time boosts, and Solr 9 rejects a JSON update that carries one + Sunspot.config.solr.index_time_boosts = IntegrationHelper.solr_version.to_i < 7 end end + def self.solr_version + @solr_version ||= Sunspot.session.send(:connection).get('admin/system')['lucene']['solr-spec-version'] + end + # Skips the example on Solr `major` and later, for behaviour that version changed def skip_from_solr(major, reason) - version = Sunspot.session.send(:connection).get('admin/system')['lucene']['solr-spec-version'] + version = IntegrationHelper.solr_version skip "#{reason} (Solr #{version})" if version.to_i >= major end diff --git a/sunspot/spec/mocks/project.rb b/sunspot/spec/mocks/project.rb index 5d2c4e4c6..f5d709308 100644 --- a/sunspot/spec/mocks/project.rb +++ b/sunspot/spec/mocks/project.rb @@ -113,7 +113,7 @@ def milestones nested :milestones do string(:only_here) { 'yes' } integer(:started_at) { 1 } - text(:notes) { 'gadget notes' } + text(:notes, :boost => 2) { 'gadget notes' } end end diff --git a/sunspot_rails/lib/sunspot/rails.rb b/sunspot_rails/lib/sunspot/rails.rb index e10211472..734fd798a 100644 --- a/sunspot_rails/lib/sunspot/rails.rb +++ b/sunspot_rails/lib/sunspot/rails.rb @@ -54,6 +54,7 @@ def master_config(sunspot_rails_configuration) config.solr.open_timeout = sunspot_rails_configuration.open_timeout config.solr.proxy = sunspot_rails_configuration.proxy config.solr.update_format = sunspot_rails_configuration.update_format + config.solr.index_time_boosts = sunspot_rails_configuration.index_time_boosts config end @@ -70,6 +71,7 @@ def slave_config(sunspot_rails_configuration) config.solr.open_timeout = sunspot_rails_configuration.open_timeout config.solr.proxy = sunspot_rails_configuration.proxy config.solr.update_format = sunspot_rails_configuration.update_format + config.solr.index_time_boosts = sunspot_rails_configuration.index_time_boosts config end end diff --git a/sunspot_rails/lib/sunspot/rails/configuration.rb b/sunspot_rails/lib/sunspot/rails/configuration.rb index 147b773f9..1d127fbac 100644 --- a/sunspot_rails/lib/sunspot/rails/configuration.rb +++ b/sunspot_rails/lib/sunspot/rails/configuration.rb @@ -305,6 +305,14 @@ def update_format @update_format ||= user_configuration_from_key('solr', 'update_format') end + # + # Whether to send index-time field and document boosts. Defaults to true. + # Set it to false on Solr 7 and later, which removed them. + # + def index_time_boosts + @index_time_boosts ||= user_configuration_from_key('solr', 'index_time_boosts') != false + end + def proxy @proxy ||= user_configuration_from_key('solr', 'proxy') end diff --git a/sunspot_rails/spec/configuration_spec.rb b/sunspot_rails/spec/configuration_spec.rb index edb6a4514..1d1af0c22 100644 --- a/sunspot_rails/spec/configuration_spec.rb +++ b/sunspot_rails/spec/configuration_spec.rb @@ -61,6 +61,10 @@ expect(@config.update_format).to be_nil end + it "should send index-time boosts when 'index_time_boosts' is not set" do + expect(@config.index_time_boosts).to eq(true) + end + it "should set 'log_level' property using Rails log level when not set" do allow(::Rails.logger).to receive(:level){ 3 } expect(@config.log_level).to eq('SEVERE') @@ -171,6 +175,10 @@ expect(@config.update_format).to eq('json') end + it "should handle the 'index_time_boosts' property when set" do + expect(@config.index_time_boosts).to eq(false) + end + it "should handle the 'proxy' property when set" do expect(@config.proxy).to eq('http://proxy.com:12345') end diff --git a/sunspot_rails/spec/rails_app/config/sunspot.yml b/sunspot_rails/spec/rails_app/config/sunspot.yml index ed1791ce9..70c68f9d7 100644 --- a/sunspot_rails/spec/rails_app/config/sunspot.yml +++ b/sunspot_rails/spec/rails_app/config/sunspot.yml @@ -24,6 +24,7 @@ config_test: read_timeout: 2 open_timeout: 0.5 update_format: json + index_time_boosts: false proxy: http://proxy.com:12345 auto_commit_after_request: false auto_commit_after_delete_request: true From beb1e065424f9f70181ccbe51c1dcde7698b8dfb Mon Sep 17 00:00:00 2001 From: Nicholas Jakobsen Date: Tue, 20 Oct 2026 23:46:38 -0700 Subject: [PATCH 07/11] ci: Run the `sunspot` and `sunspot_rails` specs on the bundled Solr as well as 9.10.1 Issue: the `sunspot` and `sunspot_rails` jobs ran only on Solr 9.10.1, so nothing tested the gem on the Solr 5.3.1 that `sunspot_solr` bundles, which upstream's CI tests against. Instead of choosing the Solr by gem, the matrix has a `solr` dimension, `bundled` and `9.10.1`, and `SOLR_IMAGE` is set from it. `sunspot_solr` runs only on `bundled`, since its specs test the Solr it bundles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TAYgm5SknHTiRNTgGA6LBY --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 257022b13..0d522643d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,12 @@ jobs: - 'head' gem: ['sunspot', 'sunspot_rails', 'sunspot_solr'] update-format: ['xml', 'json'] + # The Solr that sunspot_solr bundles, and the Solr Culture Code runs in production + solr: ['bundled', '9.10.1'] + exclude: + # sunspot_solr's own specs test the Solr it bundles + - gem: sunspot_solr + solr: '9.10.1' steps: - uses: actions/checkout@v4 - name: Set up JDK 1.8 @@ -56,5 +62,4 @@ jobs: env: GEM: ${{ matrix.gem }} UPDATE_FORMAT: ${{ matrix.update-format }} - # The Solr Culture Code runs in production. sunspot_solr's own specs test the Solr it bundles. - SOLR_IMAGE: ${{ matrix.gem != 'sunspot_solr' && 'solr:9.10.1' || '' }} + SOLR_IMAGE: ${{ matrix.solr != 'bundled' && format('solr:{0}', matrix.solr) || '' }} From 4fd7ce8a258790e64b4628cbad71ccdab26fd6fa Mon Sep 17 00:00:00 2001 From: Sampo Kuokkanen Date: Wed, 9 Sep 2026 20:36:06 +0900 Subject: [PATCH 08/11] Avoid NoMethodError when resolving DSL calls ContextBoundDelegate tries the receiver first and falls back to the calling context in a rescue, so every DSL call that belongs to the calling context raises a NoMethodError whose backtrace is then thrown away. Raising captures the whole stack at roughly 3 objects per frame, and a Rails request is already ~182 frames deep inside a search block, so each raise costs ~600 objects. In our app a block calling a few model helpers raised 70 times per request; dropping those took the request from 288,801 to 246,807 objects allocated, 14.6% less. The fast path uses respond_to? to dispatch straight to the calling context. Where respond_to? can't answer, method_missing without respond_to_missing? being the obvious case, it falls through to the old path. --- sunspot/lib/sunspot/util.rb | 33 +++++++++----- sunspot/spec/api/binding_spec.rb | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/sunspot/lib/sunspot/util.rb b/sunspot/lib/sunspot/util.rb index 85ea7d245..e64790f30 100644 --- a/sunspot/lib/sunspot/util.rb +++ b/sunspot/lib/sunspot/util.rb @@ -298,20 +298,31 @@ def respond_to_missing?(method, _) end def __proxy_method__(method, *args, **kwargs, &block) - if kwargs.empty? - @__receiver__.__send__(method.to_sym, *args, &block) - else - @__receiver__.__send__(method.to_sym, *args, **kwargs, &block) + method = method.to_sym + + # Fast path: skip the rescue below when respond_to? can say where the + # method lives, avoiding a raise on every call into the calling context. + if !@__receiver__.respond_to?(method, true) && + @__calling_context__.respond_to?(method, true) + return __dispatch__(@__calling_context__, method, args, kwargs, &block) end - rescue ::NoMethodError => e + begin - if kwargs.empty? - @__calling_context__.__send__(method.to_sym, *args, &block) - else - @__calling_context__.__send__(method.to_sym, *args, **kwargs, &block) + __dispatch__(@__receiver__, method, args, kwargs, &block) + rescue ::NoMethodError => e + begin + __dispatch__(@__calling_context__, method, args, kwargs, &block) + rescue ::NoMethodError + raise(e) end - rescue ::NoMethodError - raise(e) + end + end + + def __dispatch__(target, method, args, kwargs, &block) + if kwargs.empty? + target.__send__(method, *args, &block) + else + target.__send__(method, *args, **kwargs, &block) end end end diff --git a/sunspot/spec/api/binding_spec.rb b/sunspot/spec/api/binding_spec.rb index 14008252b..e7f6ba751 100644 --- a/sunspot/spec/api/binding_spec.rb +++ b/sunspot/spec/api/binding_spec.rb @@ -63,3 +63,80 @@ def kwargs_method(a:, b:) { a: a, b: b } end end + +describe Sunspot::Util::ContextBoundDelegate do + # Resolution must not depend on raising; the discarded backtraces are costly. + it 'does not raise NoMethodError when resolving a calling context method' do + raised = 0 + tracer = TracePoint.new(:raise) do |tp| + raised += 1 if tp.raised_exception.is_a?(NoMethodError) + end + + context = Class.new do + def resolve(receiver) + Sunspot::Util::ContextBoundDelegate + .instance_eval_with_context(receiver) { context_only_method } + end + + def context_only_method + 'from context' + end + end.new + + result = nil + tracer.enable { result = context.resolve(Object.new) } + + expect(result).to eq('from context') + expect(raised).to eq(0) + end + + # respond_to? cannot see method_missing, so this must still resolve via the + # rescue-based fallback. + it 'resolves a calling context method that only exists via method_missing' do + context = Class.new do + def resolve(receiver) + Sunspot::Util::ContextBoundDelegate + .instance_eval_with_context(receiver) { ghost_method } + end + + def method_missing(name, *args, &block) + name == :ghost_method ? 'from method_missing' : super + end + # respond_to_missing? is deliberately not defined. + end.new + + expect(context.resolve(Object.new)).to eq('from method_missing') + end + + it 'raises NoMethodError when neither the receiver nor the context responds' do + context = Class.new do + def resolve(receiver) + Sunspot::Util::ContextBoundDelegate + .instance_eval_with_context(receiver) { totally_unknown_method } + end + end.new + + expect { context.resolve(Object.new) }.to raise_error(NoMethodError) + end + + it 'prefers the receiver when both define the method' do + receiver = Class.new do + def shared_method + 'from receiver' + end + end.new + + context = Class.new do + def resolve(receiver) + Sunspot::Util::ContextBoundDelegate + .instance_eval_with_context(receiver) { shared_method } + end + + def shared_method + 'from context' + end + end.new + + expect(context.resolve(receiver)).to eq('from receiver') + end +end From 0f1accca1f3f978a81c621bdbc4904494698fe8c Mon Sep 17 00:00:00 2001 From: Sampo Kuokkanen Date: Wed, 21 Oct 2026 09:54:17 +0900 Subject: [PATCH 09/11] Keep receiver-first dispatch for receivers with method_missing The respond_to? fast path let the calling context win over receivers that handle calls in method_missing without respond_to_missing?, such as DSL::Function (function { sum(...) } in a model class method) and DSL::Fields (double inside an RSpec example). It also called respond_to? on BasicObject receivers that don't have it. Only take the fast path when the receiver has Kernel, doesn't respond to the method and uses the default method_missing, so it could only have raised. Add specs for each case, and only count raises on the test thread. --- sunspot/lib/sunspot/util.rb | 18 +++++-- sunspot/spec/api/binding_spec.rb | 86 +++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/sunspot/lib/sunspot/util.rb b/sunspot/lib/sunspot/util.rb index e64790f30..e83be0378 100644 --- a/sunspot/lib/sunspot/util.rb +++ b/sunspot/lib/sunspot/util.rb @@ -300,9 +300,10 @@ def respond_to_missing?(method, _) def __proxy_method__(method, *args, **kwargs, &block) method = method.to_sym - # Fast path: skip the rescue below when respond_to? can say where the - # method lives, avoiding a raise on every call into the calling context. - if !@__receiver__.respond_to?(method, true) && + # Fast path: skip the rescue below when the receiver can't handle the + # method, avoiding a raise on every call into the calling context. + if !__may_handle__?(@__receiver__, method) && + ::Kernel === @__calling_context__ && @__calling_context__.respond_to?(method, true) return __dispatch__(@__calling_context__, method, args, kwargs, &block) end @@ -318,6 +319,17 @@ def __proxy_method__(method, *args, **kwargs, &block) end end + KERNEL_METHOD = ::Kernel.instance_method(:method) + + # respond_to? only settles it when method_missing is the default one; + # receivers like DSL::Function answer everything through method_missing. + # Objects without Kernel (BasicObject proxies) may not have respond_to?. + def __may_handle__?(target, method) + !(::Kernel === target) || + target.respond_to?(method, true) || + KERNEL_METHOD.bind(target).call(:method_missing).owner != ::BasicObject + end + def __dispatch__(target, method, args, kwargs, &block) if kwargs.empty? target.__send__(method, *args, &block) diff --git a/sunspot/spec/api/binding_spec.rb b/sunspot/spec/api/binding_spec.rb index e7f6ba751..5c4467548 100644 --- a/sunspot/spec/api/binding_spec.rb +++ b/sunspot/spec/api/binding_spec.rb @@ -49,6 +49,50 @@ expect(value).to eq({ a: 10, b: 20 }) end + it 'should build function queries whose names the calling context also defines' do + # Like a model class method calling solr_search, where the model responds + # to ActiveRecord's #sum. + searcher = Class.new do + def search(session) + session.search(Post) do + keywords('pizza') do + boost(function { sum(:average_rating, 10) }) + end + end + end + + def sum(*) + 'from context' + end + end.new + + searcher.search(session) + expect(connection).to have_last_search_including(:bf, 'sum(average_rating_ft,10)') + end + + it 'should define typed fields whose names the calling context also defines' do + stub_const('BindingSpecDocument', Class.new) + # The example group responds to rspec-mocks' #double. + Sunspot::Setup.setup(BindingSpecDocument) { double :rating } + expect(Sunspot::Setup.for(BindingSpecDocument).field(:rating).type).to be_a(Sunspot::Type::DoubleType) + end + + it 'should prefer the record\'s method_missing in index-time blocks' do + record_class = Class.new do + def method_missing(name, *args, &block) + name == :name ? 'from record' : super + end + # respond_to_missing? is deliberately not defined. + end + # Inside a searchable block the calling context is the model class, which + # responds to Class#name. + extractor = record_class.instance_eval do + Sunspot::DataExtractor::BlockExtractor.new { name } + end + + expect(extractor.value_for(record_class.new)).to eq('from record') + end + private def test_method @@ -68,8 +112,9 @@ def kwargs_method(a:, b:) # Resolution must not depend on raising; the discarded backtraces are costly. it 'does not raise NoMethodError when resolving a calling context method' do raised = 0 + test_thread = Thread.current tracer = TracePoint.new(:raise) do |tp| - raised += 1 if tp.raised_exception.is_a?(NoMethodError) + raised += 1 if Thread.current == test_thread && tp.raised_exception.is_a?(NoMethodError) end context = Class.new do @@ -139,4 +184,43 @@ def shared_method expect(context.resolve(receiver)).to eq('from receiver') end + + it 'prefers a receiver method_missing over a context method of the same name' do + receiver = Class.new do + def method_missing(name, *args, &block) + name == :shared_method ? 'from receiver' : super + end + # respond_to_missing? is deliberately not defined. + end.new + + context = Class.new do + def resolve(receiver) + Sunspot::Util::ContextBoundDelegate + .instance_eval_with_context(receiver) { shared_method } + end + + def shared_method + 'from context' + end + end.new + + expect(context.resolve(receiver)).to eq('from receiver') + end + + it 'dispatches to a BasicObject receiver that does not implement respond_to?' do + receiver = Class.new(BasicObject) do + def method_missing(name, *args, &block) + name == :proxied_method ? 'from proxy' : super + end + end.new + + context = Class.new do + def resolve(receiver) + Sunspot::Util::ContextBoundDelegate + .instance_eval_with_context(receiver) { proxied_method } + end + end.new + + expect(context.resolve(receiver)).to eq('from proxy') + end end From 2f879292ecb3ff3d9443ffa3e05221b84c7b0a83 Mon Sep 17 00:00:00 2001 From: Nicholas Jakobsen Date: Wed, 21 Oct 2026 00:26:48 -0700 Subject: [PATCH 10/11] fix: Read `index_time_boosts` each time the indexer sends, so changing it after the first index takes effect Root cause: `Session#indexer` copied `config.solr.index_time_boosts` into the `Indexer` when it built it, on the first index. Setting it to false after that, as an app that indexes during boot before an initializer runs would, left boosts on for the life of the session (found in review). Instead of copying the value, the indexer now keeps the session's config and reads the setting in `add_documents`. New API specs cover turning the setting off after indexing, a `batch`, and an atomic update, which sends a field's boost like a full update. All three, and the existing two, fail without the stripping. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TAYgm5SknHTiRNTgGA6LBY --- sunspot/lib/sunspot/indexer.rb | 6 +++--- sunspot/lib/sunspot/session.rb | 2 +- sunspot/spec/api/indexer/fulltext_spec.rb | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/sunspot/lib/sunspot/indexer.rb b/sunspot/lib/sunspot/indexer.rb index 58498cf75..d2fda8c9c 100644 --- a/sunspot/lib/sunspot/indexer.rb +++ b/sunspot/lib/sunspot/indexer.rb @@ -9,9 +9,9 @@ module Sunspot # class Indexer #:nodoc: - def initialize(connection, index_time_boosts: true) + def initialize(connection, config) @connection = connection - @index_time_boosts = index_time_boosts + @config = config end # @@ -241,7 +241,7 @@ def prepare_atomic_update(clazz, id, updates = {}) end def add_documents(documents) - documents.each { |document| remove_boosts(document) } unless @index_time_boosts + documents.each { |document| remove_boosts(document) } if @config.solr.index_time_boosts == false @connection.add(documents) end diff --git a/sunspot/lib/sunspot/session.rb b/sunspot/lib/sunspot/session.rb index 7a10b4d40..0c87428d3 100644 --- a/sunspot/lib/sunspot/session.rb +++ b/sunspot/lib/sunspot/session.rb @@ -264,7 +264,7 @@ def connection end def indexer - @indexer ||= Indexer.new(connection, index_time_boosts: config.solr.index_time_boosts != false) + @indexer ||= Indexer.new(connection, config) end def setup_for_types(types) diff --git a/sunspot/spec/api/indexer/fulltext_spec.rb b/sunspot/spec/api/indexer/fulltext_spec.rb index 62dde3fdf..4700e5567 100644 --- a/sunspot/spec/api/indexer/fulltext_spec.rb +++ b/sunspot/spec/api/indexer/fulltext_spec.rb @@ -53,5 +53,22 @@ session.index(Photo.new) expect(connection.adds.last.first.attrs).not_to have_key(:boost) end + + it 'sends no field boost from a batch' do + session.batch { session.index(post(:title => 'A Title')) } + expect(connection.adds.last.first.field_by_name(:title_text).attrs).not_to have_key(:boost) + end + + it 'sends no field boost in an atomic update' do + session.atomic_update(Post, post.id => { :title => 'A Title' }) + expect(connection.adds.last.first.field_by_name(:title_text).attrs).not_to have_key(:boost) + end + end + + it 'sends no field boost once index_time_boosts is turned off after indexing' do + session.index(post(:title => 'A Title')) + config.solr.index_time_boosts = false + session.index(Post.new(:title => 'Another Title')) + expect(connection.adds.last.first.field_by_name(:title_text).attrs).not_to have_key(:boost) end end From 16e9111410357bb708ba304f915e4fe716df25a6 Mon Sep 17 00:00:00 2001 From: Nicholas Jakobsen Date: Wed, 21 Oct 2026 00:26:49 -0700 Subject: [PATCH 11/11] docs: Say that Solr 9 rejects only field boosts in JSON updates, and skip the field boost spec from Solr 7 The README, the setting's rdoc and the spec helper said Solr 9 rejects a JSON update carrying any index-time boost. On Solr 9.10.1 a document boost in a JSON update is accepted and ignored; only a field boost gets `400 unknown field 'boost'` (found in review). The "with field boost" integration spec kept passing on Solr 9, where no field boost is sent, so it no longer tested the boost there. It's now skipped from Solr 7, as the document boost spec beside it is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TAYgm5SknHTiRNTgGA6LBY --- README.md | 4 ++-- sunspot/lib/sunspot/configuration.rb | 4 ++-- sunspot/spec/helpers/integration_helper.rb | 2 +- sunspot/spec/integration/keyword_search_spec.rb | 1 + 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6a2452221..883c5908f 100644 --- a/README.md +++ b/README.md @@ -1507,7 +1507,7 @@ class Post < ActiveRecord::Base end ``` -Solr 7 removed index-time boosts, for fields and documents alike. It ignores them in XML updates, and Solr 9 rejects a JSON update that carries one with `unknown field 'boost'`. On Solr 7 and later, set `index_time_boosts` to false so Sunspot leaves them out, and boost at query time instead: `:default_boost` on the field, or `boost_fields` in the search. See [Upgrading to Solr 9](#upgrading-to-solr-9). +Solr 7 removed index-time boosts, for fields and documents alike. Solr 9 ignores them in XML updates and a document boost in JSON updates, but rejects a JSON update that carries a field boost with `unknown field 'boost'`. On Solr 7 and later, set `index_time_boosts` to false so Sunspot leaves them out, and boost at query time instead: `:default_boost` on the field, or `boost_fields` in the search. See [Upgrading to Solr 9](#upgrading-to-solr-9). ### Stored Fields @@ -1752,7 +1752,7 @@ In both cases, the solr images by default expects cores to be placed in `/opt/so Sunspot's specs pass on Solr 9.10.1 with the configset in `ci/solr9`. Coming from the Solr that `sunspot_solr` bundles, these are the changes that affect Sunspot: * **The schema.** Solr 9 won't load the bundled schema. `ci/solr9` is a port of it: point fields with docValues in place of trie fields, `LatLonPointSpatialField` in place of `LatLonType`, and `StandardFilterFactory`, `defaultSearchField` and `solrQueryParser` taken out. Use it as a starting point for your core, and reindex everything after switching, since the field types change. -* **Index-time boosts.** Solr 7 removed them, and Solr 9 rejects a JSON update that carries one. Set `index_time_boosts` to false ([Index-Time Boosts](#index-time-boosts)). +* **Index-time boosts.** Solr 7 removed them, and Solr 9 rejects a JSON update that carries a field boost. Set `index_time_boosts` to false ([Index-Time Boosts](#index-time-boosts)). * **Matching every keyword.** Since Solr 7, edismax no longer requires every term by default. `ci/solr9/conf/solrconfig.xml` sets `mm=100%` on `/select`, so a search for several keywords still matches only documents that have them all, as it did before. * **Scores.** BM25 has been the default similarity since `luceneMatchVersion` 6, so relative scores differ from the bundled Solr's, and results with close scores can change order. * **Time range facets.** `facet :published_at, :time_range => …` sends `facet.date`, which Solr 6 removed. Use a [query facet](#query-facets) with a row for each range instead. diff --git a/sunspot/lib/sunspot/configuration.rb b/sunspot/lib/sunspot/configuration.rb index d1ec3bbe7..deecc50ac 100644 --- a/sunspot/lib/sunspot/configuration.rb +++ b/sunspot/lib/sunspot/configuration.rb @@ -8,8 +8,8 @@ module Sunspot # Sunspot.config.solr.index_time_boosts:: # Whether to send the field and document boosts declared with +:boost+ # and +boost+ (default: true). Solr 7 removed index-time boosts, and - # Solr 9 rejects a JSON update that carries one, so set it to false on - # Solr 7 and later. + # Solr 9 rejects a JSON update that carries a field boost, so set it to + # false on Solr 7 and later. # Sunspot.config.pagination.default_per_page:: # Solr always paginates its results. This sets Sunspot's default result # count per page if it is not explicitly specified in the query. diff --git a/sunspot/spec/helpers/integration_helper.rb b/sunspot/spec/helpers/integration_helper.rb index 1920b1ac1..bcf5db52b 100644 --- a/sunspot/spec/helpers/integration_helper.rb +++ b/sunspot/spec/helpers/integration_helper.rb @@ -4,7 +4,7 @@ def self.included(base) Sunspot.config.solr.url = ENV['SOLR_URL'] || 'http://localhost:8983/solr/default' Sunspot.config.solr.update_format = ENV['UPDATE_FORMAT'].to_sym if ENV['UPDATE_FORMAT'] Sunspot.reset!(true) - # Solr 7 removed index-time boosts, and Solr 9 rejects a JSON update that carries one + # Solr 7 removed index-time boosts, and Solr 9 rejects a JSON update that carries a field boost Sunspot.config.solr.index_time_boosts = IntegrationHelper.solr_version.to_i < 7 end end diff --git a/sunspot/spec/integration/keyword_search_spec.rb b/sunspot/spec/integration/keyword_search_spec.rb index 305fb7fc2..fcffcb58c 100644 --- a/sunspot/spec/integration/keyword_search_spec.rb +++ b/sunspot/spec/integration/keyword_search_spec.rb @@ -124,6 +124,7 @@ end it 'should assign a higher score to the result matching the higher-boosted field' do + skip_from_solr 7, 'Solr 7 removed index-time field boosts' search = Sunspot.search(Post) { keywords 'rhinoceros' } expect(search.hits.map { |hit| hit.primary_key }).to eq( @posts.map { |post| post.id.to_s }