| // Copyright 2016 The Go Authors. All rights reserved. |
| // |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file or at |
| // https://developers.google.com/open-source/licenses/bsd. |
| |
| package database |
| |
| import ( |
| "bytes" |
| "context" |
| "errors" |
| "fmt" |
| "log" |
| "math" |
| "strings" |
| "unicode" |
| |
| "google.golang.org/appengine/search" |
| |
| "github.com/golang/gddo/doc" |
| ) |
| |
| func (p *Package) Load(fields []search.Field, meta *search.DocumentMetadata) error { |
| for _, f := range fields { |
| switch f.Name { |
| case "Name": |
| if v, ok := f.Value.(search.Atom); ok { |
| p.Name = string(v) |
| } |
| case "Path": |
| if v, ok := f.Value.(string); ok { |
| p.Path = v |
| } |
| case "Synopsis": |
| if v, ok := f.Value.(string); ok { |
| p.Synopsis = v |
| } |
| case "ImportCount": |
| if v, ok := f.Value.(float64); ok { |
| p.ImportCount = int(v) |
| } |
| case "Stars": |
| if v, ok := f.Value.(float64); ok { |
| p.Stars = int(v) |
| } |
| case "Score": |
| if v, ok := f.Value.(float64); ok { |
| p.Score = v |
| } |
| } |
| } |
| if p.Path == "" { |
| return errors.New("Invalid document: missing Path field") |
| } |
| for _, f := range meta.Facets { |
| if f.Name == "Fork" { |
| p.Fork = f.Value.(search.Atom) == "true" |
| } |
| } |
| return nil |
| } |
| |
| func (p *Package) Save() ([]search.Field, *search.DocumentMetadata, error) { |
| fields := []search.Field{ |
| {Name: "Name", Value: search.Atom(p.Name)}, |
| {Name: "Path", Value: p.Path}, |
| {Name: "Synopsis", Value: p.Synopsis}, |
| {Name: "Score", Value: p.Score}, |
| {Name: "ImportCount", Value: float64(p.ImportCount)}, |
| {Name: "Stars", Value: float64(p.Stars)}, |
| } |
| fork := fmt.Sprint(p.Fork) // "true" or "false" |
| meta := &search.DocumentMetadata{ |
| // Customize the rank property by the product of the package score and |
| // natural logarithm of the import count. Rank must be a positive integer. |
| // Use 1 as minimum rank and keep 3 digits of precision to distinguish |
| // close ranks. |
| Rank: int(math.Max(1, 1000*p.Score*math.Log(math.E+float64(p.ImportCount)))), |
| Facets: []search.Facet{ |
| {Name: "Fork", Value: search.Atom(fork)}, |
| }, |
| } |
| return fields, meta, nil |
| } |
| |
| // putIndex creates or updates a package entry in the search index. id identifies the document in the index. |
| // If pdoc is non-nil, putIndex will update the package's name, path and synopsis supplied by pdoc. |
| // pdoc must be non-nil for a package's first call to putIndex. |
| // putIndex updates the Score to score, if non-negative. |
| func putIndex(c context.Context, pdoc *doc.Package, id string, score float64, importCount int) error { |
| if id == "" { |
| return errors.New("indexae: no id assigned") |
| } |
| idx, err := search.Open("packages") |
| if err != nil { |
| return err |
| } |
| |
| var pkg Package |
| if err := idx.Get(c, id, &pkg); err != nil { |
| if err != search.ErrNoSuchDocument { |
| return err |
| } else if pdoc == nil { |
| // Cannot update a non-existing document. |
| return errors.New("indexae: cannot create new document with nil pdoc") |
| } |
| // No such document in the index, fall through. |
| } |
| |
| // Update document information accordingly. |
| if pdoc != nil { |
| pkg.Name = pdoc.Name |
| pkg.Path = pdoc.ImportPath |
| pkg.Synopsis = pdoc.Synopsis |
| pkg.Stars = pdoc.Stars |
| pkg.Fork = pdoc.Fork |
| } |
| if score >= 0 { |
| pkg.Score = score |
| } |
| pkg.ImportCount = importCount |
| |
| if _, err := idx.Put(c, id, &pkg); err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| // searchAE searches the packages index for a given query. A path-like query string |
| // will be passed in unchanged, whereas single words will be stemmed. |
| func searchAE(c context.Context, q string) ([]Package, error) { |
| index, err := search.Open("packages") |
| if err != nil { |
| return nil, err |
| } |
| var pkgs []Package |
| opt := &search.SearchOptions{ |
| Limit: 100, |
| } |
| for it := index.Search(c, parseQuery2(q), opt); ; { |
| var p Package |
| _, err := it.Next(&p) |
| if err == search.Done { |
| break |
| } |
| if err != nil { |
| return nil, err |
| } |
| pkgs = append(pkgs, p) |
| } |
| return pkgs, nil |
| } |
| |
| func parseQuery2(q string) string { |
| var buf bytes.Buffer |
| for _, s := range strings.FieldsFunc(q, isTermSep2) { |
| if strings.ContainsAny(s, "./") { |
| // Quote terms with / or . for path like query. |
| fmt.Fprintf(&buf, "%q ", s) |
| } else { |
| // Stem for single word terms. |
| fmt.Fprintf(&buf, "~%v ", s) |
| } |
| } |
| return buf.String() |
| } |
| |
| func isTermSep2(r rune) bool { |
| return unicode.IsSpace(r) || |
| r != '.' && r != '/' && unicode.IsPunct(r) || |
| unicode.IsSymbol(r) |
| } |
| |
| func deleteIndex(c context.Context, id string) error { |
| idx, err := search.Open("packages") |
| if err != nil { |
| return err |
| } |
| return idx.Delete(c, id) |
| } |
| |
| // PurgeIndex deletes all the packages from the search index. |
| // TODO(shantuo): wrap this with db and use db.RemoteClient to create the context. |
| func PurgeIndex(c context.Context) error { |
| idx, err := search.Open("packages") |
| if err != nil { |
| return err |
| } |
| n := 0 |
| |
| for it := idx.List(c, &search.ListOptions{IDsOnly: true}); ; n++ { |
| var pkg Package |
| id, err := it.Next(&pkg) |
| if err == search.Done { |
| break |
| } |
| if err != nil { |
| return err |
| } |
| if err := idx.Delete(c, id); err != nil { |
| log.Printf("Failed to delete package %s: %v", id, err) |
| continue |
| } |
| } |
| log.Printf("Purged %d packages from the search index.", n) |
| return nil |
| } |