Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion analysis/token/compound/dict.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func DictionaryCompoundFilterConstructor(config map[string]interface{}, cache *r
}
dictTokenMap, err := cache.TokenMapNamed(dictTokenMapName)
if err != nil {
return nil, fmt.Errorf("error building dict compound words filter: %v", err)
return nil, fmt.Errorf("error building dict compound words filter: %w", err)
}
return NewDictionaryCompoundFilter(dictTokenMap, minWordSize, minSubWordSize, maxSubWordSize, onlyLongestMatch), nil
}
Expand Down
2 changes: 1 addition & 1 deletion analysis/token/elision/elision.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func ElisionFilterConstructor(config map[string]interface{}, cache *registry.Cac
}
articlesTokenMap, err := cache.TokenMapNamed(articlesTokenMapName)
if err != nil {
return nil, fmt.Errorf("error building elision filter: %v", err)
return nil, fmt.Errorf("error building elision filter: %w", err)
}
return NewElisionFilter(articlesTokenMap), nil
}
Expand Down
2 changes: 1 addition & 1 deletion analysis/token/keyword/keyword.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func KeyWordMarkerFilterConstructor(config map[string]interface{}, cache *regist
}
keywordsTokenMap, err := cache.TokenMapNamed(keywordsTokenMapName)
if err != nil {
return nil, fmt.Errorf("error building keyword marker filter: %v", err)
return nil, fmt.Errorf("error building keyword marker filter: %w", err)
}
return NewKeyWordMarkerFilter(keywordsTokenMap), nil
}
Expand Down
2 changes: 1 addition & 1 deletion analysis/token/stop/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func StopTokensFilterConstructor(config map[string]interface{}, cache *registry.
}
stopTokenMap, err := cache.TokenMapNamed(stopTokenMapName)
if err != nil {
return nil, fmt.Errorf("error building stop words filter: %v", err)
return nil, fmt.Errorf("error building stop words filter: %w", err)
}
return NewStopTokensFilter(stopTokenMap), nil
}
Expand Down
130 changes: 130 additions & 0 deletions deprecated_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright (c) 2026 Couchbase, Inc.
//
// Licensed 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.

package bleve

import (
"errors"
"strings"
"testing"

"github.com/blevesearch/bleve/v2/mapping"
"github.com/blevesearch/bleve/v2/registry"
"github.com/blevesearch/bleve/v2/util"
)

// An index whose mapping references a deprecated analysis component (as one
// created while the component was still shipped would) can be built and
// reopened; only the affected field loses its analysis.
func TestIndexWithDeprecatedAnalyzer(t *testing.T) {
tmpIndexPath := createTmpIndexPath(t)
defer cleanupTmpIndexPath(t, tmpIndexPath)

var im *mapping.IndexMappingImpl
err := util.UnmarshalJSON([]byte(`{
"analysis": {"analyzers": {"my_he": {"type": "custom",
"tokenizer": "hebrew", "token_filters": ["stop_he"]}}},
"default_mapping": {"properties": {
"he": {"fields": [{"name": "he", "type": "text", "analyzer": "he", "index": true}]},
"he2": {"fields": [{"name": "he2", "type": "text", "analyzer": "my_he", "index": true}]},
"en": {"fields": [{"name": "en", "type": "text", "analyzer": "en", "index": true}]}
}}}`), &im)
if err != nil {
t.Fatal(err)
}
if err = im.Validate(); !errors.Is(err, registry.ErrDeprecatedComponent) {
t.Fatalf("expected deprecated component error from Validate, got %v", err)
}

idx, err := New(tmpIndexPath, im)
if err != nil {
t.Fatalf("expected index to build, got %v", err)
}
err = idx.Index("doc", map[string]interface{}{
"he": "שלום עולם",
"he2": "shalom olam",
"en": "hello worlds",
})
if err != nil {
t.Fatal(err)
}
err = idx.Close()
if err != nil {
t.Fatal(err)
}

idx, err = Open(tmpIndexPath)
if err != nil {
t.Fatalf("expected index to reopen, got %v", err)
}
defer func() {
if err := idx.Close(); err != nil {
t.Fatal(err)
}
}()

search := func(q string) (uint64, error) {
res, err := idx.Search(NewSearchRequest(NewQueryStringQuery(q)))
if err != nil {
return 0, err
}
return res.Total, nil
}

// the unaffected field keeps working
if n, err := search("en:world"); err != nil || n != 1 {
t.Fatalf("expected 1 hit on the en field, got %d, %v", n, err)
}

// queries that need to analyze text with a deprecated analyzer fail, and
// say why
for _, q := range []string{"he:shalom", `he:"shalom olam"`, "he2:shalom"} {
if _, err := search(q); !errors.Is(err, registry.ErrDeprecatedComponent) {
t.Fatalf("expected %q to fail as deprecated, got %v", q, err)
}
}
mq := NewMatchQuery("world")
mq.SetField("en")
mq.Analyzer = "he"
if _, err = idx.Search(NewSearchRequest(mq)); !errors.Is(err, registry.ErrDeprecatedComponent) {
t.Fatalf("expected an explicit he analyzer to fail as deprecated, got %v", err)
}
mq.Analyzer = "nope"
if _, err = idx.Search(NewSearchRequest(mq)); err == nil ||
errors.Is(err, registry.ErrDeprecatedComponent) ||
!strings.Contains(err.Error(), "no analyzer named 'nope' registered") {
t.Fatalf("expected an unknown analyzer to fail as before, got %v", err)
}

// queries that do not analyze text run against what is on disk; the
// affected fields were indexed without analysis, so as a single term
tq := NewTermQuery("שלום עולם")
tq.SetField("he")
res, err := idx.Search(NewSearchRequest(tq))
if err != nil || res.Total != 1 {
t.Fatalf("expected the whole value as one term, got %v, %v", res, err)
}
}

func TestNewIndexWithUnknownAnalyzerStillFails(t *testing.T) {
tmpIndexPath := createTmpIndexPath(t)
defer cleanupTmpIndexPath(t, tmpIndexPath)

im := NewIndexMapping()
im.DefaultAnalyzer = "nope"
_, err := New(tmpIndexPath, im)
if err == nil || errors.Is(err, registry.ErrDeprecatedComponent) {
t.Fatalf("expected a plain unknown analyzer error, got %v", err)
}
}
20 changes: 17 additions & 3 deletions index_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package bleve

import (
"context"
"errors"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -72,9 +73,22 @@ func indexStorePath(path string) string {
return path + string(os.PathSeparator) + storePath
}

// validateExistingMapping validates the mapping of an index that is being
// opened (pre-existing), as opposed to newly defined. Unlike IndexMappingImpl.Validate,
// it allows a reference to a deprecated analysis component to pass, since the
// mapping is otherwise valid: rejecting it here would make an index that was
// created while the component was still available impossible to open or rebuild.
func validateExistingMapping(m mapping.IndexMapping) error {
err := m.Validate()
if errors.Is(err, registry.ErrDeprecatedComponent) {
return nil
}
return err
}

func newIndexUsing(path string, mapping mapping.IndexMapping, indexType string, kvstore string, kvconfig map[string]interface{}) (*indexImpl, error) {
// first validate the mapping
err := mapping.Validate()
err := validateExistingMapping(mapping)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -252,7 +266,7 @@ func openIndexUsing(path string, runtimeConfig map[string]interface{}) (rv *inde
}

// validate the mapping
err = im.Validate()
err = validateExistingMapping(im)
if err != nil {
// no longer return usable index on error because there
// is a chance the index is not open at this stage
Expand All @@ -261,7 +275,7 @@ func openIndexUsing(path string, runtimeConfig map[string]interface{}) (rv *inde

// Validate and update the index with the new mapping
if um != nil && ui != nil {
err = um.Validate()
err = validateExistingMapping(um)
if err != nil {
return nil, err
}
Expand Down
17 changes: 12 additions & 5 deletions mapping/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ type customAnalysis struct {
SynonymSources map[string]map[string]interface{} `json:"synonym_sources,omitempty"`
}

// registerAll defines every custom analysis component in the mapping's cache.
// A component that cannot be defined because it is built out of a deprecated
// component is left undefined rather than failing the mapping, so that the
// mapping of an existing index still unmarshals; Validate reports it.
func (c *customAnalysis) registerAll(i *IndexMappingImpl) error {
for name, config := range c.CharFilters {
_, err := i.cache.DefineCharFilter(name, config)
Expand All @@ -47,9 +51,12 @@ func (c *customAnalysis) registerAll(i *IndexMappingImpl) error {
for name := range todo {
config := c.Tokenizers[name]
_, err := i.cache.DefineTokenizer(name, config)
if err != nil {
if err = skipDeprecated(err, &i.deprecatedAnalysis); err != nil {
errs = append(errs, err)
} else {
// a deprecated tokenizer counts as progress too, so
// that the tokenizers built on it are retried and
// reported as deprecated rather than as missing
delete(todo, name)
registered++
}
Expand All @@ -62,19 +69,19 @@ func (c *customAnalysis) registerAll(i *IndexMappingImpl) error {
}
for name, config := range c.TokenMaps {
_, err := i.cache.DefineTokenMap(name, config)
if err != nil {
if err = skipDeprecated(err, &i.deprecatedAnalysis); err != nil {
return err
}
}
for name, config := range c.TokenFilters {
_, err := i.cache.DefineTokenFilter(name, config)
if err != nil {
if err = skipDeprecated(err, &i.deprecatedAnalysis); err != nil {
return err
}
}
for name, config := range c.Analyzers {
_, err := i.cache.DefineAnalyzer(name, config)
if err != nil {
if err = skipDeprecated(err, &i.deprecatedAnalysis); err != nil {
return err
}
}
Expand All @@ -86,7 +93,7 @@ func (c *customAnalysis) registerAll(i *IndexMappingImpl) error {
}
for name, config := range c.SynonymSources {
_, err := i.cache.DefineSynonymSource(name, config)
if err != nil {
if err = skipDeprecated(err, &i.deprecatedAnalysis); err != nil {
return err
}
}
Expand Down
35 changes: 35 additions & 0 deletions mapping/deprecated.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) 2026 Couchbase, Inc.
//
// Licensed 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.

package mapping

import (
"errors"

"github.com/blevesearch/bleve/v2/registry"
)

// skipDeprecated lets validation carry on past a deprecated analysis
// component. It returns nil for an error wrapping
// registry.ErrDeprecatedComponent, keeping the first such error in
// *deprecated, and returns any other error unchanged.
func skipDeprecated(err error, deprecated *error) error {
if err != nil && errors.Is(err, registry.ErrDeprecatedComponent) {
if *deprecated == nil {
*deprecated = err
}
return nil
}
return err
}
Loading
Loading