Repository: owncloud/ocis
Pull request #3783 (2022-05-12 11:36:12)

Improve search
This PR fixes a few smaller issues with the file search like searching for files or directories with spaces or digits and enables advanced search features. In addition to the plain filename search it is now possible to specify search criteria on metadata as well, e.g.

1. Search for files small than 100 bytes

    `size:<100`

1. Search for pdfs that were modified after May 11th

      `+name:*pdf +mtime:>"2022-05-11"`


update-docs[bot]: Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would create a [changelog](https://github.com/owncloud/ocis/blob/master/changelog/README.md) item based on your changes.


Patch:
diff --git a/extensions/search/pkg/search/index/index.go b/extensions/search/pkg/search/index/index.go
index bc06ba4665c..07797f7d4e8 100644
--- a/extensions/search/pkg/search/index/index.go
+++ b/extensions/search/pkg/search/index/index.go
@@ -165,15 +165,16 @@ func (i *Index) Purge(id *sprovider.ResourceId) error {
 }
 
 // Move update the path of an entry and all its children
-func (i *Index) Move(ri *sprovider.ResourceInfo, fullPath string) error {
-	doc, err := i.getEntity(idToBleveId(ri.Id))
+func (i *Index) Move(id *sprovider.ResourceId, fullPath string) error {
+	bleveId := idToBleveId(id)
+	doc, err := i.getEntity(bleveId)
 	if err != nil {
 		return err
 	}
 	oldName := doc.Path
 	newName := utils.MakeRelativePath(fullPath)
 
-	doc, err = i.updateEntity(idToBleveId(ri.Id), func(doc *indexDocument) {
+	doc, err = i.updateEntity(bleveId, func(doc *indexDocument) {
 		doc.Path = newName
 		doc.Name = path.Base(newName)
 	})
@@ -211,7 +212,7 @@ func (i *Index) Search(ctx context.Context, req *searchsvc.SearchIndexRequest) (
 	deletedQuery := bleve.NewBoolFieldQuery(false)
 	deletedQuery.SetField("Deleted")
 	query := bleve.NewConjunctionQuery(
-		bleve.NewQueryStringQuery("Name:"+strings.ToLower(req.Query)),
+		bleve.NewQueryStringQuery(req.Query),
 		deletedQuery, // Skip documents that have been marked as deleted
 		bleve.NewQueryStringQuery("RootID:"+req.Ref.ResourceId.StorageId+"!"+req.Ref.ResourceId.OpaqueId), // Limit search to the space
 		bleve.NewQueryStringQuery("Path:"+utils.MakeRelativePath(path.Join(req.Ref.Path, "/"))+"*"),       // Limit search to this directory in the space
diff --git a/extensions/search/pkg/search/mocks/IndexClient.go b/extensions/search/pkg/search/mocks/IndexClient.go
index f43f9f3a986..d2286517a43 100644
--- a/extensions/search/pkg/search/mocks/IndexClient.go
+++ b/extensions/search/pkg/search/mocks/IndexClient.go
@@ -30,13 +30,13 @@ func (_m *IndexClient) Add(ref *providerv1beta1.Reference, ri *providerv1beta1.R
 	return r0
 }
 
-// Delete provides a mock function with given fields: ri
-func (_m *IndexClient) Delete(ri *providerv1beta1.ResourceId) error {
-	ret := _m.Called(ri)
+// Delete provides a mock function with given fields: id
+func (_m *IndexClient) Delete(id *providerv1beta1.ResourceId) error {
+	ret := _m.Called(id)
 
 	var r0 error
 	if rf, ok := ret.Get(0).(func(*providerv1beta1.ResourceId) error); ok {
-		r0 = rf(ri)
+		r0 = rf(id)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -65,13 +65,13 @@ func (_m *IndexClient) DocCount() (uint64, error) {
 	return r0, r1
 }
 
-// Move provides a mock function with given fields: ri, path
-func (_m *IndexClient) Move(ri *providerv1beta1.ResourceInfo, path string) error {
-	ret := _m.Called(ri, path)
+// Move provides a mock function with given fields: id, fullPath
+func (_m *IndexClient) Move(id *providerv1beta1.ResourceId, fullPath string) error {
+	ret := _m.Called(id, fullPath)
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(*providerv1beta1.ResourceInfo, string) error); ok {
-		r0 = rf(ri, path)
+	if rf, ok := ret.Get(0).(func(*providerv1beta1.ResourceId, string) error); ok {
+		r0 = rf(id, fullPath)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -79,13 +79,13 @@ func (_m *IndexClient) Move(ri *providerv1beta1.ResourceInfo, path string) error
 	return r0
 }
 
-// Purge provides a mock function with given fields: ri
-func (_m *IndexClient) Purge(ri *providerv1beta1.ResourceId) error {
-	ret := _m.Called(ri)
+// Purge provides a mock function with given fields: id
+func (_m *IndexClient) Purge(id *providerv1beta1.ResourceId) error {
+	ret := _m.Called(id)
 
 	var r0 error
 	if rf, ok := ret.Get(0).(func(*providerv1beta1.ResourceId) error); ok {
-		r0 = rf(ri)
+		r0 = rf(id)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -93,13 +93,13 @@ func (_m *IndexClient) Purge(ri *providerv1beta1.ResourceId) error {
 	return r0
 }
 
-// Restore provides a mock function with given fields: ri
-func (_m *IndexClient) Restore(ri *providerv1beta1.ResourceId) error {
-	ret := _m.Called(ri)
+// Restore provides a mock function with given fields: id
+func (_m *IndexClient) Restore(id *providerv1beta1.ResourceId) error {
+	ret := _m.Called(id)
 
 	var r0 error
 	if rf, ok := ret.Get(0).(func(*providerv1beta1.ResourceId) error); ok {
-		r0 = rf(ri)
+		r0 = rf(id)
 	} else {
 		r0 = ret.Error(0)
 	}
diff --git a/extensions/search/pkg/search/provider/events.go b/extensions/search/pkg/search/provider/events.go
index d243c523778..641c3010a99 100644
--- a/extensions/search/pkg/search/provider/events.go
+++ b/extensions/search/pkg/search/provider/events.go
@@ -58,11 +58,11 @@ func (p *Provider) handleEvent(ev interface{}) {
 
 		statRes, err := p.statResource(ref, owner)
 		if err != nil {
-			p.logger.Error().Err(err).Msg("failed to stat the changed resource")
+			p.logger.Error().Err(err).Msg("failed to stat the moved resource")
 			return
 		}
 		if statRes.Status.Code != rpc.Code_CODE_OK {
-			p.logger.Error().Interface("statRes", statRes).Msg("failed to stat the changed resource")
+			p.logger.Error().Interface("statRes", statRes).Msg("failed to stat the moved resource")
 			return
 		}
 
@@ -76,9 +76,9 @@ func (p *Provider) handleEvent(ev interface{}) {
 			return
 		}
 
-		err = p.indexClient.Move(statRes.Info, gpRes.Path)
+		err = p.indexClient.Move(statRes.Info.Id, gpRes.Path)
 		if err != nil {
-			p.logger.Error().Err(err).Msg("failed to restore the changed resource in the index")
+			p.logger.Error().Err(err).Msg("failed to move the changed resource in the index")
 		}
 		return
 	case events.ContainerCreated:
@@ -126,7 +126,6 @@ func (p *Provider) statResource(ref *provider.Reference, owner *user.User) (*pro
 		return nil, err
 	}
 
-	// Stat changed resource resource
 	return p.gwClient.Stat(ownerCtx, &provider.StatRequest{Ref: ref})
 }
 
@@ -136,7 +135,6 @@ func (p *Provider) getPath(id *provider.ResourceId, owner *user.User) (*provider
 		return nil, err
 	}
 
-	// Stat changed resource resource
 	return p.gwClient.GetPath(ownerCtx, &provider.GetPathRequest{ResourceId: id})
 }
 
diff --git a/extensions/search/pkg/search/provider/searchprovider.go b/extensions/search/pkg/search/provider/searchprovider.go
index 94040563548..067d9665eba 100644
--- a/extensions/search/pkg/search/provider/searchprovider.go
+++ b/extensions/search/pkg/search/provider/searchprovider.go
@@ -125,7 +125,7 @@ func (p *Provider) Search(ctx context.Context, req *searchsvc.SearchRequest) (*s
 
 		_, rootStorageID := storagespace.SplitStorageID(space.Root.StorageId)
 		res, err := p.indexClient.Search(ctx, &searchsvc.SearchIndexRequest{
-			Query: req.Query,
+			Query: formatQuery(req.Query),
 			Ref: &searchmsg.Reference{
 				ResourceId: &searchmsg.ResourceID{
 					StorageId: space.Root.StorageId,
@@ -217,3 +217,18 @@ func (p *Provider) logDocCount() {
 	}
 	p.logger.Debug().Interface("count", c).Msg("new document count")
 }
+
+func formatQuery(q string) string {
+	query := q
+	fields := []string{"RootID", "Path", "ID", "Name", "Size", "Mtime", "MimeType", "Type"}
+	for _, field := range fields {
+		query = strings.ReplaceAll(query, strings.ToLower(field)+":", field+":")
+	}
+
+	if strings.Contains(query, ":") {
+		return query // Sophisticated field based search
+	}
+
+	// this is a basic filename search
+	return "Name:*" + strings.ReplaceAll(strings.ToLower(query), " ", `\ `) + "*"
+}
diff --git a/extensions/search/pkg/search/search.go b/extensions/search/pkg/search/search.go
index 12df203422e..0a8bedd2662 100644
--- a/extensions/search/pkg/search/search.go
+++ b/extensions/search/pkg/search/search.go
@@ -38,9 +38,9 @@ type ProviderClient interface {
 type IndexClient interface {
 	Search(ctx context.Context, req *searchsvc.SearchIndexRequest) (*searchsvc.SearchIndexResponse, error)
 	Add(ref *providerv1beta1.Reference, ri *providerv1beta1.ResourceInfo) error
-	Move(ri *providerv1beta1.ResourceInfo, path string) error
-	Delete(ri *providerv1beta1.ResourceId) error
-	Restore(ri *providerv1beta1.ResourceId) error
-	Purge(ri *providerv1beta1.ResourceId) error
+	Move(id *providerv1beta1.ResourceId, fullPath string) error
+	Delete(id *providerv1beta1.ResourceId) error
+	Restore(id *providerv1beta1.ResourceId) error
+	Purge(id *providerv1beta1.ResourceId) error
 	DocCount() (uint64, error)
 }
diff --git a/extensions/webdav/pkg/service/v0/search.go b/extensions/webdav/pkg/service/v0/search.go
index e90382984fa..817f401a140 100644
--- a/extensions/webdav/pkg/service/v0/search.go
+++ b/extensions/webdav/pkg/service/v0/search.go
@@ -9,6 +9,7 @@ import (
 	"strconv"
 	"time"
 
+	provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
 	revactx "github.com/cs3org/reva/v2/pkg/ctx"
 	"github.com/owncloud/ocis/v2/extensions/webdav/pkg/net"
 	"github.com/owncloud/ocis/v2/extensions/webdav/pkg/prop"
@@ -43,7 +44,7 @@ func (g Webdav) Search(w http.ResponseWriter, r *http.Request) {
 	ctx := revactx.ContextSetToken(r.Context(), t)
 	ctx = metadata.Set(ctx, revactx.TokenHeader, t)
 	rsp, err := g.searchClient.Search(ctx, &searchsvc.SearchRequest{
-		Query: "*" + rep.SearchFiles.Search.Pattern + "*",
+		Query: rep.SearchFiles.Search.Pattern,
 	})
 	if err != nil {
 		e := merrors.Parse(err.Error())
@@ -128,9 +129,15 @@ func matchToPropResponse(ctx context.Context, match *searchmsg.Match) (*propfind
 	propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontenttype", match.Entity.MimeType))
 
 	size := strconv.FormatUint(match.Entity.Size, 10)
-	propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:size", size))
-	propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontentlength", size))
-
+	if match.Entity.Type == uint64(provider.ResourceType_RESOURCE_TYPE_CONTAINER) {
+		propstatOK.Prop = append(propstatOK.Prop, prop.Raw("d:resourcetype", "<d:collection/>"))
+		propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:size", size))
+	} else {
+		propstatOK.Prop = append(propstatOK.Prop,
+			prop.Escaped("d:resourcetype", ""),
+			prop.Escaped("d:getcontentlength", size),
+		)
+	}
 	// TODO find name for score property
 	score := strconv.FormatFloat(float64(match.Score), 'f', -1, 64)
 	propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:score", score))


Test patch:
diff --git a/extensions/search/pkg/search/index/index_test.go b/extensions/search/pkg/search/index/index_test.go
index 7c46c64b227..582df6f4f67 100644
--- a/extensions/search/pkg/search/index/index_test.go
+++ b/extensions/search/pkg/search/index/index_test.go
@@ -24,25 +24,9 @@ var _ = Describe("Index", func() {
 			StorageId: "storageid",
 			OpaqueId:  "rootopaqueid",
 		}
-		ref = &sprovider.Reference{
-			ResourceId: rootId,
-			Path:       "./foo.pdf",
-		}
-		ri = &sprovider.ResourceInfo{
-			Id: &sprovider.ResourceId{
-				StorageId: "storageid",
-				OpaqueId:  "opaqueid",
-			},
-			ParentId: &sprovider.ResourceId{
-				StorageId: "storageid",
-				OpaqueId:  "someopaqueid",
-			},
-			Path:     "Foo.pdf",
-			Size:     12345,
-			Type:     sprovider.ResourceType_RESOURCE_TYPE_FILE,
-			MimeType: "application/pdf",
-			Mtime:    &typesv1beta1.Timestamp{Seconds: 4000},
-		}
+		filename  string
+		ref       *sprovider.Reference
+		ri        *sprovider.ResourceInfo
 		parentRef = &sprovider.Reference{
 			ResourceId: rootId,
 			Path:       "./my/sudbir",
@@ -76,7 +60,7 @@ var _ = Describe("Index", func() {
 			Mtime: &typesv1beta1.Timestamp{Seconds: 4000},
 		}
 
-		assertDocCount = func(rootId *sprovider.ResourceId, query string, expectedCount int) {
+		assertDocCount = func(rootId *sprovider.ResourceId, query string, expectedCount int) []*searchmsg.Match {
 			res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
 				Query: query,
 				Ref: &searchmsg.Reference{
@@ -87,11 +71,14 @@ var _ = Describe("Index", func() {
 				},
 			})
 			ExpectWithOffset(1, err).ToNot(HaveOccurred())
-			ExpectWithOffset(1, len(res.Matches)).To(Equal(expectedCount))
+			ExpectWithOffset(1, len(res.Matches)).To(Equal(expectedCount), "query returned unexpected number of results: "+query)
+			return res.Matches
 		}
 	)
 
 	BeforeEach(func() {
+		filename = "Foo.pdf"
+
 		mapping, err := index.BuildMapping()
 		Expect(err).ToNot(HaveOccurred())
 
@@ -102,6 +89,28 @@ var _ = Describe("Index", func() {
 		Expect(err).ToNot(HaveOccurred())
 	})
 
+	JustBeforeEach(func() {
+		ref = &sprovider.Reference{
+			ResourceId: rootId,
+			Path:       "./" + filename,
+		}
+		ri = &sprovider.ResourceInfo{
+			Id: &sprovider.ResourceId{
+				StorageId: "storageid",
+				OpaqueId:  "opaqueid",
+			},
+			ParentId: &sprovider.ResourceId{
+				StorageId: "storageid",
+				OpaqueId:  "someopaqueid",
+			},
+			Path:     filename,
+			Size:     12345,
+			Type:     sprovider.ResourceType_RESOURCE_TYPE_FILE,
+			MimeType: "application/pdf",
+			Mtime:    &typesv1beta1.Timestamp{Seconds: 4000},
+		}
+	})
+
 	Describe("New", func() {
 		It("returns a new index instance", func() {
 			i, err := index.New(bleveIndex)
@@ -119,163 +128,139 @@ var _ = Describe("Index", func() {
 	})
 
 	Describe("Search", func() {
-		Context("with a file in the root of the space", func() {
-			BeforeEach(func() {
+		Context("by other fields than filename", func() {
+			JustBeforeEach(func() {
 				err := i.Add(ref, ri)
 				Expect(err).ToNot(HaveOccurred())
 			})
 
-			It("scopes the search to the specified space", func() {
-				res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
-					Ref: &searchmsg.Reference{
-						ResourceId: &searchmsg.ResourceID{
-							StorageId: "differentstorageid",
-							OpaqueId:  "differentopaqueid",
-						},
-					},
-					Query: "foo.pdf",
-				})
-				Expect(err).ToNot(HaveOccurred())
-				Expect(res).ToNot(BeNil())
-				Expect(len(res.Matches)).To(Equal(0))
+			It("finds files by size", func() {
+				assertDocCount(ref.ResourceId, `Size:12345`, 1)
+				assertDocCount(ref.ResourceId, `Size:>1000`, 1)
+				assertDocCount(ref.ResourceId, `Size:<100000`, 1)
+
+				assertDocCount(ref.ResourceId, `Size:12344`, 0)
+				assertDocCount(ref.ResourceId, `Size:<1000`, 0)
+				assertDocCount(ref.ResourceId, `Size:>100000`, 0)
 			})
+		})
 
-			It("limits the search to the relevant fields", func() {
-				res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
-					Ref: &searchmsg.Reference{
-						ResourceId: &searchmsg.ResourceID{
-							StorageId: ref.ResourceId.StorageId,
-							OpaqueId:  ref.ResourceId.OpaqueId,
-						},
-					},
-					Query: "*" + ref.ResourceId.OpaqueId + "*",
-				})
+		Context("by filename", func() {
+			It("finds files with spaces in the filename", func() {
+				ri.Path = "Foo oo.pdf"
+				ref.Path = "./" + ri.Path
+				err := i.Add(ref, ri)
 				Expect(err).ToNot(HaveOccurred())
-				Expect(res).ToNot(BeNil())
-				Expect(len(res.Matches)).To(Equal(0))
+
+				assertDocCount(ref.ResourceId, `Name:foo\ o*`, 1)
 			})
 
-			It("returns all desired fields", func() {
-				res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
-					Ref: &searchmsg.Reference{
-						ResourceId: &searchmsg.ResourceID{
-							StorageId: ref.ResourceId.StorageId,
-							OpaqueId:  ref.ResourceId.OpaqueId,
-						},
-					},
-					Query: "foo.pdf",
-				})
+			It("finds files by digits in the filename", func() {
+				ri.Path = "12345.pdf"
+				ref.Path = "./" + ri.Path
+				err := i.Add(ref, ri)
 				Expect(err).ToNot(HaveOccurred())
-				Expect(res).ToNot(BeNil())
-				Expect(len(res.Matches)).To(Equal(1))
-				match := res.Matches[0]
-				Expect(match.Entity.Ref.ResourceId.OpaqueId).To(Equal(ref.ResourceId.OpaqueId))
-				Expect(match.Entity.Ref.Path).To(Equal(ref.Path))
-				Expect(match.Entity.Id.OpaqueId).To(Equal(ri.Id.OpaqueId))
-				Expect(match.Entity.Name).To(Equal(ri.Path))
-				Expect(match.Entity.Size).To(Equal(ri.Size))
-				Expect(match.Entity.Type).To(Equal(uint64(ri.Type)))
-				Expect(match.Entity.MimeType).To(Equal(ri.MimeType))
-				Expect(match.Entity.Deleted).To(BeFalse())
-				Expect(uint64(match.Entity.LastModifiedTime.AsTime().Unix())).To(Equal(ri.Mtime.Seconds))
-			})
 
-			It("finds files by name, prefix or substring match", func() {
-				queries := []string{"foo.pdf", "foo*", "*oo.p*"}
-				for _, query := range queries {
-					res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
-						Ref: &searchmsg.Reference{
-							ResourceId: &searchmsg.ResourceID{
-								StorageId: ref.ResourceId.StorageId,
-								OpaqueId:  ref.ResourceId.OpaqueId,
-							},
-						},
-						Query: query,
-					})
-					Expect(err).ToNot(HaveOccurred())
-					Expect(res).ToNot(BeNil())
-					Expect(len(res.Matches)).To(Equal(1), "query returned no result: "+query)
-					Expect(res.Matches[0].Entity.Ref.ResourceId.OpaqueId).To(Equal(ref.ResourceId.OpaqueId))
-					Expect(res.Matches[0].Entity.Ref.Path).To(Equal(ref.Path))
-					Expect(res.Matches[0].Entity.Id.OpaqueId).To(Equal(ri.Id.OpaqueId))
-					Expect(res.Matches[0].Entity.Name).To(Equal(ri.Path))
-					Expect(res.Matches[0].Entity.Size).To(Equal(ri.Size))
-				}
+				assertDocCount(ref.ResourceId, `Name:1234*`, 1)
 			})
 
-			It("is case-insensitive", func() {
-				res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
-					Ref: &searchmsg.Reference{
-						ResourceId: &searchmsg.ResourceID{
-							StorageId: ref.ResourceId.StorageId,
-							OpaqueId:  ref.ResourceId.OpaqueId,
-						},
-					},
-					Query: "Foo*",
+			Context("with a file in the root of the space", func() {
+				JustBeforeEach(func() {
+					err := i.Add(ref, ri)
+					Expect(err).ToNot(HaveOccurred())
 				})
-				Expect(err).ToNot(HaveOccurred())
-				Expect(res).ToNot(BeNil())
-				Expect(len(res.Matches)).To(Equal(1))
-			})
 
-			Context("and an additional file in a subdirectory", func() {
-				var (
-					nestedRef *sprovider.Reference
-					nestedRI  *sprovider.ResourceInfo
-				)
-
-				BeforeEach(func() {
-					nestedRef = &sprovider.Reference{
-						ResourceId: &sprovider.ResourceId{
-							StorageId: "storageid",
-							OpaqueId:  "rootopaqueid",
-						},
-						Path: "./nested/nestedpdf.pdf",
-					}
-					nestedRI = &sprovider.ResourceInfo{
-						Id: &sprovider.ResourceId{
-							StorageId: "storageid",
-							OpaqueId:  "nestedopaqueid",
-						},
-						Path: "nestedpdf.pdf",
-						Size: 12345,
+				It("scopes the search to the specified space", func() {
+					resourceId := &sprovider.ResourceId{
+						StorageId: "differentstorageid",
+						OpaqueId:  "differentopaqueid",
 					}
-					err := i.Add(nestedRef, nestedRI)
-					Expect(err).ToNot(HaveOccurred())
+					assertDocCount(resourceId, `Name:foo.pdf`, 0)
 				})
 
-				It("finds files living deeper in the tree by filename, prefix or substring match", func() {
-					queries := []string{"nestedpdf.pdf", "nested*", "*tedpdf.*"}
+				It("limits the search to the specified fields", func() {
+					assertDocCount(ref.ResourceId, "Name:*"+ref.ResourceId.OpaqueId+"*", 0)
+				})
+
+				It("returns all desired fields", func() {
+					matches := assertDocCount(ref.ResourceId, "Name:foo.pdf", 1)
+					match := matches[0]
+					Expect(match.Entity.Ref.ResourceId.OpaqueId).To(Equal(ref.ResourceId.OpaqueId))
+					Expect(match.Entity.Ref.Path).To(Equal(ref.Path))
+					Expect(match.Entity.Id.OpaqueId).To(Equal(ri.Id.OpaqueId))
+					Expect(match.Entity.Name).To(Equal(ri.Path))
+					Expect(match.Entity.Size).To(Equal(ri.Size))
+					Expect(match.Entity.Type).To(Equal(uint64(ri.Type)))
+					Expect(match.Entity.MimeType).To(Equal(ri.MimeType))
+					Expect(match.Entity.Deleted).To(BeFalse())
+					Expect(uint64(match.Entity.LastModifiedTime.AsTime().Unix())).To(Equal(ri.Mtime.Seconds))
+				})
+
+				It("finds files by name, prefix or substring match", func() {
+					queries := []string{"foo.pdf", "foo*", "*oo.p*"}
 					for _, query := range queries {
+						matches := assertDocCount(ref.ResourceId, query, 1)
+						Expect(matches[0].Entity.Ref.ResourceId.OpaqueId).To(Equal(ref.ResourceId.OpaqueId))
+						Expect(matches[0].Entity.Ref.Path).To(Equal(ref.Path))
+						Expect(matches[0].Entity.Id.OpaqueId).To(Equal(ri.Id.OpaqueId))
+						Expect(matches[0].Entity.Name).To(Equal(ri.Path))
+						Expect(matches[0].Entity.Size).To(Equal(ri.Size))
+					}
+				})
+
+				It("uses a lower-case index", func() {
+					assertDocCount(ref.ResourceId, "Name:foo*", 1)
+					assertDocCount(ref.ResourceId, "Name:Foo*", 0)
+				})
+
+				Context("and an additional file in a subdirectory", func() {
+					var (
+						nestedRef *sprovider.Reference
+						nestedRI  *sprovider.ResourceInfo
+					)
+
+					BeforeEach(func() {
+						nestedRef = &sprovider.Reference{
+							ResourceId: &sprovider.ResourceId{
+								StorageId: "storageid",
+								OpaqueId:  "rootopaqueid",
+							},
+							Path: "./nested/nestedpdf.pdf",
+						}
+						nestedRI = &sprovider.ResourceInfo{
+							Id: &sprovider.ResourceId{
+								StorageId: "storageid",
+								OpaqueId:  "nestedopaqueid",
+							},
+							Path: "nestedpdf.pdf",
+							Size: 12345,
+						}
+						err := i.Add(nestedRef, nestedRI)
+						Expect(err).ToNot(HaveOccurred())
+					})
+
+					It("finds files living deeper in the tree by filename, prefix or substring match", func() {
+						queries := []string{"nestedpdf.pdf", "nested*", "*tedpdf.*"}
+						for _, query := range queries {
+							assertDocCount(ref.ResourceId, query, 1)
+						}
+					})
+
+					It("does not find the higher levels when limiting the searched directory", func() {
 						res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
 							Ref: &searchmsg.Reference{
 								ResourceId: &searchmsg.ResourceID{
 									StorageId: ref.ResourceId.StorageId,
 									OpaqueId:  ref.ResourceId.OpaqueId,
 								},
+								Path: "./nested/",
 							},
-							Query: query,
+							Query: "Name:foo.pdf",
 						})
 						Expect(err).ToNot(HaveOccurred())
 						Expect(res).ToNot(BeNil())
-						Expect(len(res.Matches)).To(Equal(1), "query returned no result: "+query)
-					}
-				})
-
-				It("does not find the higher levels when limiting the searched directory", func() {
-					res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
-						Ref: &searchmsg.Reference{
-							ResourceId: &searchmsg.ResourceID{
-								StorageId: ref.ResourceId.StorageId,
-								OpaqueId:  ref.ResourceId.OpaqueId,
-							},
-							Path: "./nested/",
-						},
-						Query: "foo.pdf",
+						Expect(len(res.Matches)).To(Equal(0))
 					})
-					Expect(err).ToNot(HaveOccurred())
-					Expect(res).ToNot(BeNil())
-					Expect(len(res.Matches)).To(Equal(0))
 				})
 			})
 		})
@@ -366,23 +351,13 @@ var _ = Describe("Index", func() {
 			Expect(err).ToNot(HaveOccurred())
 
 			parentRi.Path = "newname"
-			err = i.Move(parentRi, "./somewhere/else/newname")
+			err = i.Move(parentRi.Id, "./somewhere/else/newname")
 			Expect(err).ToNot(HaveOccurred())
 
 			assertDocCount(rootId, "subdir", 0)
 
-			res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
-				Query: "child.pdf",
-				Ref: &searchmsg.Reference{
-					ResourceId: &searchmsg.ResourceID{
-						StorageId: rootId.StorageId,
-						OpaqueId:  rootId.OpaqueId,
-					},
-				},
-			})
-			Expect(err).ToNot(HaveOccurred())
-			Expect(len(res.Matches)).To(Equal(1))
-			Expect(res.Matches[0].Entity.Ref.Path).To(Equal("./somewhere/else/newname/child.pdf"))
+			matches := assertDocCount(rootId, "Name:child.pdf", 1)
+			Expect(matches[0].Entity.Ref.Path).To(Equal("./somewhere/else/newname/child.pdf"))
 		})
 	})
 })
diff --git a/extensions/search/pkg/search/provider/events_test.go b/extensions/search/pkg/search/provider/events_test.go
index 9de17c5df34..a36b36aef99 100644
--- a/extensions/search/pkg/search/provider/events_test.go
+++ b/extensions/search/pkg/search/provider/events_test.go
@@ -156,8 +156,8 @@ var _ = Describe("Searchprovider", func() {
 				Status: status.NewOK(ctx),
 				Path:   "./new/path.pdf",
 			}, nil)
-			indexClient.On("Move", mock.MatchedBy(func(riToIndex *sprovider.ResourceInfo) bool {
-				return riToIndex.Id.OpaqueId == ri.Id.OpaqueId
+			indexClient.On("Move", mock.MatchedBy(func(id *sprovider.ResourceId) bool {
+				return id.OpaqueId == ri.Id.OpaqueId
 			}), "./new/path.pdf").Return(nil).Run(func(args mock.Arguments) {
 				called = true
 			})
diff --git a/extensions/search/pkg/search/provider/searchprovider_test.go b/extensions/search/pkg/search/provider/searchprovider_test.go
index 37f59ff1cdb..5f727827350 100644
--- a/extensions/search/pkg/search/provider/searchprovider_test.go
+++ b/extensions/search/pkg/search/provider/searchprovider_test.go
@@ -146,6 +146,50 @@ var _ = Describe("Searchprovider", func() {
 				}, nil)
 			})
 
+			It("lowercases the filename", func() {
+				p.Search(ctx, &searchsvc.SearchRequest{
+					Query: "Foo.pdf",
+				})
+				indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
+					return req.Query == "Name:*foo.pdf*"
+				}))
+			})
+
+			It("does not mess with field-based searches", func() {
+				p.Search(ctx, &searchsvc.SearchRequest{
+					Query: "Size:<10",
+				})
+				indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
+					return req.Query == "Size:<10"
+				}))
+			})
+
+			It("uppercases field names", func() {
+				tests := []struct {
+					Original string
+					Expected string
+				}{
+					{Original: "size:<100", Expected: "Size:<100"},
+				}
+				for _, test := range tests {
+					p.Search(ctx, &searchsvc.SearchRequest{
+						Query: test.Original,
+					})
+					indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
+						return req.Query == test.Expected
+					}))
+				}
+			})
+
+			It("escapes special characters", func() {
+				p.Search(ctx, &searchsvc.SearchRequest{
+					Query: "Foo oo.pdf",
+				})
+				indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
+					return req.Query == `Name:*foo\ oo.pdf*`
+				}))
+			})
+
 			It("searches the personal user space", func() {
 				res, err := p.Search(ctx, &searchsvc.SearchRequest{
 					Query: "foo",
@@ -160,7 +204,7 @@ var _ = Describe("Searchprovider", func() {
 				Expect(match.Entity.Ref.Path).To(Equal("./path/to/Foo.pdf"))
 
 				indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
-					return req.Query == "foo" && req.Ref.ResourceId.OpaqueId == personalSpace.Root.OpaqueId && req.Ref.Path == ""
+					return req.Query == "Name:*foo*" && req.Ref.ResourceId.OpaqueId == personalSpace.Root.OpaqueId && req.Ref.Path == ""
 				}))
 			})
 		})
@@ -225,7 +269,7 @@ var _ = Describe("Searchprovider", func() {
 				}, nil)
 
 				res, err := p.Search(ctx, &searchsvc.SearchRequest{
-					Query: "foo",
+					Query: "Foo",
 				})
 				Expect(err).ToNot(HaveOccurred())
 				Expect(res).ToNot(BeNil())
@@ -237,7 +281,7 @@ var _ = Describe("Searchprovider", func() {
 				Expect(match.Entity.Ref.Path).To(Equal("./to/Shared.pdf"))
 
 				indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
-					return req.Query == "foo" && req.Ref.ResourceId.StorageId == grantSpace.Root.StorageId && req.Ref.Path == "./grant/path"
+					return req.Query == "Name:*foo*" && req.Ref.ResourceId.StorageId == grantSpace.Root.StorageId && req.Ref.Path == "./grant/path"
 				}))
 			})