blob: e87ca34bed4f51ff8ea7e37a26debc7865700f9d [file] [log] [blame]
// Copyright 2019 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.
package postgres
import (
"context"
"crypto/md5"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"sort"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/lib/pq"
"go.opencensus.io/stats/view"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/derrors"
"golang.org/x/pkgsite/internal/experiment"
"golang.org/x/pkgsite/internal/licenses"
"golang.org/x/pkgsite/internal/testing/sample"
)
func TestPathTokens(t *testing.T) {
t.Parallel()
testDB, release := acquire(t)
defer release()
for _, test := range []struct {
path string
want []string
want2 []string
}{
{
path: "context",
want: []string{"context"},
},
{
path: "rsc.io/quote",
want: []string{
"quote",
"rsc",
"rsc.io",
"rsc.io/quote",
},
},
{
path: "k8s.io/client-go",
want: []string{
"client",
"client-go",
"go",
"k8s",
"k8s.io",
"k8s.io/client-go",
},
},
{
path: "github.com/foo/bar",
want: []string{
"bar",
"foo",
"foo/bar",
"github.com/foo",
"github.com/foo/bar",
},
want2: []string{
"bar",
"foo",
"foo/bar",
// TO_TSVECTOR produces this because github.com/foo and
// github.com/foo/bar is tokenized to "github.com" with the
// mapping "host".
// Removing this mapping would remove all hosts (anything with
// a "." will be parsed as the empty string).
"github.com",
"github.com/foo",
"github.com/foo/bar",
},
},
{
path: "github.com/julieqiu/api-demo",
want: []string{
"api",
"api-demo",
"demo",
"github.com/julieqiu",
"github.com/julieqiu/api-demo",
"julieqiu",
"julieqiu/api-demo",
},
want2: []string{
"api",
"api-demo",
"demo",
"github.com",
"github.com/julieqiu",
"github.com/julieqiu/api-demo",
"julieqiu",
"julieqiu/api-demo",
},
},
{
path: "golang.org/x/tools/go/packages",
want: []string{
"go",
"go/packages",
"golang.org/x",
"golang.org/x/tools",
"golang.org/x/tools/go",
"golang.org/x/tools/go/packages",
"packages",
"tools",
"tools/go",
"tools/go/packages",
"x",
"x/tools",
"x/tools/go",
"x/tools/go/packages",
},
want2: []string{
"go",
"go/packages",
// TO_TSVECTOR produces this because golang.org/<anything>
// is tokenized to "golang.org".
"golang.org",
"golang.org/x",
"golang.org/x/tools",
"golang.org/x/tools/go",
"golang.org/x/tools/go/packages",
"packages",
"tools",
"tools/go",
"tools/go/packages",
"x",
"x/tools",
"x/tools/go",
"x/tools/go/packages",
},
},
{
path: "/example.com/foo-bar///package///",
want: []string{
"bar",
"example",
"example.com",
"example.com/foo-bar",
"example.com/foo-bar///package",
"foo",
"foo-bar",
"foo-bar///package",
"package",
},
want2: []string{
// This is produced by the TO_TSVECTOR output of anything containing a
// "/package" element.
"/package",
"bar",
"example",
"example.com",
"example.com/foo-bar",
"example.com/foo-bar///package",
"foo",
"foo-bar",
// "foo-bar///package" from the want array above is is not
// indexed. It is tokenized into "foo-bar"
// (asciihword,"Hyphenated word, all ASCII"), and "/package"
// (file). hword_asciipart and hword_asciipart are dropped from
// the symbols configuration, but indexed for the simple
// configuration.
"package",
},
},
{
path: "cloud.google.com/go/cmd/go-cloud-debug-agent/internal/valuecollector",
want: []string{
"agent",
"cloud",
"cloud.google.com",
"cloud.google.com/go",
"cloud.google.com/go/cmd",
"cloud.google.com/go/cmd/go-cloud-debug-agent",
"cloud.google.com/go/cmd/go-cloud-debug-agent/internal",
"cloud.google.com/go/cmd/go-cloud-debug-agent/internal/valuecollector",
"cmd",
"cmd/go-cloud-debug-agent",
"cmd/go-cloud-debug-agent/internal",
"cmd/go-cloud-debug-agent/internal/valuecollector",
"debug",
"go",
"go-cloud-debug-agent",
"go-cloud-debug-agent/internal",
"go-cloud-debug-agent/internal/valuecollector",
"go/cmd",
"go/cmd/go-cloud-debug-agent",
"go/cmd/go-cloud-debug-agent/internal",
"go/cmd/go-cloud-debug-agent/internal/valuecollector",
"internal",
"internal/valuecollector",
"valuecollector",
},
want2: []string{
// "/internal" and "/internal/valuecollector"
// are produced by the TO_TSVECTOR output of anything
// containing "/package".
"/internal",
"/internal/valuecollector",
"agent",
"cloud",
"cloud.google.com",
"cloud.google.com/go",
"cloud.google.com/go/cmd",
"cloud.google.com/go/cmd/go-cloud-debug-agent",
"cloud.google.com/go/cmd/go-cloud-debug-agent/internal",
"cloud.google.com/go/cmd/go-cloud-debug-agent/internal/valuecollector",
"cmd",
"cmd/go-cloud-debug-agent",
"cmd/go-cloud-debug-agent/internal",
"cmd/go-cloud-debug-agent/internal/valuecollector",
"debug",
"go",
"go-cloud-debug-agent",
// go/cmd/go-cloud-debug-agent/internal and
// go/cmd/go-cloud-debug-agent/internal/valuecollector
// are not indexed, because they are considered the combination
// of a asciihword (go-cloud-debug-agent) and file (/internal),
// but are not themselves words.
// Because of the hyphens in the *leading* part, they are also
// not considered a file (This could be changed if the hyphens
// were encoded as some other character, but it is not clear
// which one. Underscores are treated as blanks).
"go/cmd",
"go/cmd/go-cloud-debug-agent",
"go/cmd/go-cloud-debug-agent/internal",
"go/cmd/go-cloud-debug-agent/internal/valuecollector",
"internal",
"internal/valuecollector",
"valuecollector",
},
},
{
path: "code.cloud.gitlab.google.k8s.io",
want: []string{
"cloud",
"code.cloud.gitlab.google.k8s.io",
"k8s",
},
},
{
path: "/",
want: nil,
},
} {
t.Run(strings.ReplaceAll(test.path, "/", "_"), func(t *testing.T) {
got := GeneratePathTokens(test.path)
if diff := cmp.Diff(test.want, got); diff != "" {
t.Errorf("generatePathTokens(%q) mismatch (-want +got):\n%s", test.path, diff)
}
var tsv string
q := "SELECT TO_TSVECTOR('symbols', $1)"
arg := strings.Join(got, " ")
if err := testDB.db.QueryRow(context.Background(), q, arg).Scan(&tsv); err != nil {
t.Fatal(err)
}
var got2 []string
parts := strings.Split(tsv, "'")
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if strings.HasPrefix(p, ":") {
continue
}
got2 = append(got2, p)
}
if test.want2 == nil {
test.want2 = test.want
}
if diff := cmp.Diff(test.want2, got2); diff != "" {
t.Errorf("%s: mismatch (-want +got):\n%s", strings.Replace(q, "$1", arg, 1), diff)
}
})
}
}
// importGraph constructs a simple import graph where all importers import
// one popular package. For performance purposes, all importers are added to
// a single importing module.
func importGraph(popularPath, importerModule string, importerCount int) []*internal.Module {
m := sample.Module(popularPath, "v1.2.3", "")
m.Packages()[0].Imports = nil
// Try to improve the ts_rank of the 'foo' search term.
m.Packages()[0].Documentation[0].Synopsis = "foo"
m.Units[0].Readme.Contents = "foo"
mods := []*internal.Module{m}
if importerCount > 0 {
m := sample.Module(importerModule, "v1.2.3")
for i := 0; i < importerCount; i++ {
name := fmt.Sprintf("importer%d", i)
fullPath := importerModule + "/" + name
u := &internal.Unit{
UnitMeta: *sample.UnitMeta(fullPath, importerModule, m.Version, name, true),
Documentation: []*internal.Documentation{sample.Doc},
Imports: []string{popularPath},
}
sample.AddUnit(m, u)
}
mods = append(mods, m)
}
return mods
}
// resultGuard returns a 'guard' func for the search result ordering specified
// by resultOrder. When called with a searcher name, this func blocks until
// that result may be processed, then returns a callback to release the next
// result in the series.
//
// This is used to control the order of search results in hedgedSearch.
func resultGuard(t *testing.T, resultOrder []string) func(string) func() {
done := make(map[string](chan struct{}))
// waitFor maps [search type] -> [the search type is should wait for]
waitFor := make(map[string]string)
for i := 0; i < len(resultOrder); i++ {
done[resultOrder[i]] = make(chan struct{})
if i > 0 {
waitFor[resultOrder[i]] = resultOrder[i-1]
}
}
guardTestResult := func(source string) func() {
if await, ok := waitFor[source]; ok {
<-done[await]
}
return func() { close(done[source]) }
}
return guardTestResult
}
func TestSearch(t *testing.T) {
// Cannot be run in parallel with other search tests, because it reads
// metrics before and after (see responseDelta below).
ctx := context.Background()
t.Run("no grouping", func(t *testing.T) {
testSearch(t, ctx)
})
t.Run("grouping", func(t *testing.T) {
testSearch(t, experiment.NewContext(ctx, internal.ExperimentSearchGrouping))
})
}
func testSearch(t *testing.T, ctx context.Context) {
tests := []struct {
label string
modules []*internal.Module
resultOrder []string
wantSource string
wantResults []string
wantTotal uint64
}{
{
label: "single package from popular",
modules: importGraph("foo.com/A", "", 0),
resultOrder: []string{"popular", "deep"},
wantSource: "popular",
wantResults: []string{"foo.com/A"},
wantTotal: 1,
},
{
label: "single package from deep",
modules: importGraph("foo.com/A", "", 0),
resultOrder: []string{"deep", "popular"},
wantSource: "deep",
wantResults: []string{"foo.com/A"},
wantTotal: 1,
},
{
label: "empty results",
modules: []*internal.Module{},
resultOrder: []string{"deep", "popular"},
wantSource: "deep",
wantResults: nil,
},
{
label: "both popular and unpopular results",
modules: importGraph("foo.com/popular", "bar.com/foo", 10),
resultOrder: []string{"popular", "deep"},
wantSource: "popular",
wantResults: []string{"foo.com/popular", "bar.com/foo/importer0"},
wantTotal: 100, // popular assumes 100 results
},
{
label: "popular before deep",
modules: append(importGraph("foo.com/popularA", "bar.com", 60),
importGraph("foo.com/popularB", "baz.com/foo", 70)...),
resultOrder: []string{"popular", "deep"},
wantSource: "popular",
wantResults: []string{"foo.com/popularB", "foo.com/popularA"},
wantTotal: 100, // popular assumes 100 results
},
{
label: "deep before popular",
modules: append(importGraph("foo.com/popularA", "bar.com/foo", 60),
importGraph("foo.com/popularB", "bar.com/foo", 70)...),
resultOrder: []string{"deep", "popular"},
wantSource: "deep",
wantResults: []string{"foo.com/popularB", "foo.com/popularA"},
wantTotal: 72,
},
// Adding a test for *very* popular results requires ~300 importers
// minimum, which is pretty slow to set up at the moment (~5 seconds), and
// doesn't add much additional value.
}
view.Register(SearchResponseCount)
defer view.Unregister(SearchResponseCount)
responses := make(map[string]int64)
// responseDelta captures the change in the SearchResponseCount metric.
responseDelta := func() map[string]int64 {
rows, err := view.RetrieveData(SearchResponseCount.Name)
if err != nil {
t.Fatal(err)
}
delta := make(map[string]int64)
for _, row := range rows {
// Tags[0] should always be the SearchSource. For simplicity we assume
// this.
source := row.Tags[0].Value
count := row.Data.(*view.CountData).Value
if d := count - responses[source]; d != 0 {
delta[source] = d
}
responses[source] = count
}
return delta
}
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
testDB, release := acquire(t)
defer release()
for _, m := range test.modules {
MustInsertModule(ctx, t, testDB, m)
}
if _, err := testDB.UpdateSearchDocumentsImportedByCount(ctx); err != nil {
t.Fatal(err)
}
guardTestResult := resultGuard(t, test.resultOrder)
resp, err := testDB.hedgedSearch(ctx, "foo", 2, 0, 100, pkgSearchers, guardTestResult)
if err != nil {
t.Fatal(err)
}
if resp.source != test.wantSource {
t.Errorf("hedgedSearch(): got source %q, want %q", resp.source, test.wantSource)
}
var got []string
for _, r := range resp.results {
got = append(got, r.PackagePath)
}
if diff := cmp.Diff(test.wantResults, got); diff != "" {
t.Errorf("hedgedSearch() mismatch (-want +got)\n%s", diff)
}
if len(resp.results) > 0 && resp.results[0].NumResults != test.wantTotal {
t.Errorf("NumResults = %d, want %d", resp.results[0].NumResults, test.wantTotal)
}
// Finally, validate that metrics are updated correctly
gotDelta := responseDelta()
wantDelta := map[string]int64{test.wantSource: 1}
if diff := cmp.Diff(wantDelta, gotDelta); diff != "" {
t.Errorf("SearchResponseCount: unexpected delta (-want +got):\n%s", diff)
}
})
}
}
func TestSearchErrors(t *testing.T) {
t.Parallel()
ctx := context.Background()
// errorIn returns a copy of searchers for which searcherName returns an
// error.
errorIn := func(searcherName string) map[string]searcher {
newSearchers := make(map[string]searcher)
for name, search := range pkgSearchers {
if name == searcherName {
name := name
newSearchers[name] = func(*DB, context.Context, string, int, int, int) searchResponse {
return searchResponse{
source: name,
err: errors.New("bad"),
}
}
} else {
newSearchers[name] = search
}
}
return newSearchers
}
tests := []struct {
label string
searchers map[string]searcher // allows injecting errors.
resultOrder []string
wantSource string
wantErr bool
}{
{
label: "error in first result",
searchers: errorIn("popular"),
resultOrder: []string{"popular", "deep"},
wantErr: true,
},
{
label: "return before error",
searchers: errorIn("deep"),
resultOrder: []string{"popular", "deep"},
wantSource: "popular",
},
{
label: "counted result before error",
searchers: errorIn("popular"),
resultOrder: []string{"deep", "popular"},
wantSource: "deep",
},
}
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
testDB, release := acquire(t)
defer release()
modules := importGraph("foo.com/A", "", 0)
for _, v := range modules {
MustInsertModule(ctx, t, testDB, v)
}
if _, err := testDB.UpdateSearchDocumentsImportedByCount(ctx); err != nil {
t.Fatal(err)
}
guardTestResult := resultGuard(t, test.resultOrder)
resp, err := testDB.hedgedSearch(ctx, "foo", 2, 0, 100, test.searchers, guardTestResult)
if (err != nil) != test.wantErr {
t.Fatalf("hedgedSearch(): got error %v, want error: %t", err, test.wantErr)
}
if test.wantErr {
return
}
if resp.source != test.wantSource {
t.Errorf("hedgedSearch(): got source %q, want %q", resp.source, test.wantSource)
}
})
}
}
func TestInsertSearchDocumentAndSearch(t *testing.T) {
t.Parallel()
ctx := context.Background()
var (
modGoCDK = "gocloud.dev"
pkgGoCDK = &internal.Unit{
UnitMeta: internal.UnitMeta{
Name: "cloud",
Path: "gocloud.dev/cloud",
IsRedistributable: true, // required because some test cases depend on the README contents
},
Documentation: []*internal.Documentation{{
GOOS: sample.GOOS,
GOARCH: sample.GOARCH,
Synopsis: "Package cloud contains a library and tools for open cloud development in Go. The Go Cloud Development Kit (Go CDK)",
Source: []byte{},
}},
}
modKube = "k8s.io"
pkgKube = &internal.Unit{
UnitMeta: internal.UnitMeta{
Name: "client-go",
Path: "k8s.io/client-go",
IsRedistributable: true, // required because some test cases depend on the README contents
},
Documentation: []*internal.Documentation{{
GOOS: sample.GOOS,
GOARCH: sample.GOARCH,
Synopsis: "Package client-go implements a Go client for Kubernetes.",
Source: []byte{},
}},
}
kubeResult = func(score float64, numResults uint64) *SearchResult {
return &SearchResult{
Name: pkgKube.Name,
PackagePath: pkgKube.Path,
Synopsis: pkgKube.Documentation[0].Synopsis,
Licenses: []string{"MIT"},
CommitTime: sample.CommitTime,
Version: sample.VersionString,
ModulePath: modKube,
Score: score,
NumResults: numResults,
}
}
goCDKResult = func(score float64, numResults uint64) *SearchResult {
return &SearchResult{
Name: pkgGoCDK.Name,
PackagePath: pkgGoCDK.Path,
Synopsis: pkgGoCDK.Documentation[0].Synopsis,
Licenses: []string{"MIT"},
CommitTime: sample.CommitTime,
Version: sample.VersionString,
ModulePath: modGoCDK,
Score: score,
NumResults: numResults,
}
}
offset = func(r *SearchResult, n int) *SearchResult {
r.Offset = n
return r
}
)
const (
packageScore = 0.6079270839691162
goAndCDKScore = 0.999817967414856
cloudScore = 0.8654518127441406
)
for _, test := range []struct {
name string
packages map[string]*internal.Unit
limit, offset int
searchQuery string
want []*SearchResult
}{
{
name: "two documents, single term search",
searchQuery: "package",
packages: map[string]*internal.Unit{
modGoCDK: pkgGoCDK,
modKube: pkgKube,
},
want: []*SearchResult{
goCDKResult(packageScore, 2),
offset(kubeResult(packageScore, 2), 1),
},
},
{
name: "two documents, single term search, two results limit 1 offset 0",
limit: 1,
offset: 0,
searchQuery: "package",
packages: map[string]*internal.Unit{
modKube: pkgKube,
modGoCDK: pkgGoCDK,
},
want: []*SearchResult{
goCDKResult(packageScore, 2),
},
},
{
name: "two documents, single term search, two results limit 1 offset 1",
limit: 1,
offset: 1,
searchQuery: "package",
packages: map[string]*internal.Unit{
modGoCDK: pkgGoCDK,
modKube: pkgKube,
},
want: []*SearchResult{
offset(kubeResult(packageScore, 2), 1),
},
},
{
name: "two documents, multiple term search",
searchQuery: "go & cdk",
packages: map[string]*internal.Unit{
modGoCDK: pkgGoCDK,
modKube: pkgKube,
},
want: []*SearchResult{
goCDKResult(goAndCDKScore, 1),
},
},
{
name: "one document, single term search",
searchQuery: "cloud",
packages: map[string]*internal.Unit{
modGoCDK: pkgGoCDK,
},
want: []*SearchResult{
goCDKResult(cloudScore, 1),
},
},
} {
for method, searcher := range pkgSearchers {
t.Run(test.name+":"+method, func(t *testing.T) {
testDB, release := acquire(t)
defer release()
for modulePath, pkg := range test.packages {
pkg.Licenses = sample.LicenseMetadata()
m := sample.Module(modulePath, sample.VersionString)
sample.AddUnit(m, pkg)
MustInsertModule(ctx, t, testDB, m)
}
if test.limit < 1 {
test.limit = 10
}
got := searcher(testDB, ctx, test.searchQuery, test.limit, test.offset, 100)
if got.err != nil {
t.Fatal(got.err)
}
// Normally done by hedgedSearch, but we're bypassing that.
if err := testDB.addPackageDataToSearchResults(ctx, got.results); err != nil {
t.Fatal(err)
}
if len(got.results) != len(test.want) {
t.Errorf("testDB.Search(%v, %d, %d) mismatch: len(got) = %d, want = %d\n", test.searchQuery, test.limit, test.offset, len(got.results), len(test.want))
}
// The searchers differ in these two fields.
opt := cmpopts.IgnoreFields(SearchResult{}, "Approximate", "NumResults")
if diff := cmp.Diff(test.want, got.results, opt); diff != "" {
t.Errorf("testDB.Search(%v, %d, %d) mismatch (-want +got):\n%s", test.searchQuery, test.limit, test.offset, diff)
}
})
}
}
}
func TestSearchPenalties(t *testing.T) {
// Verify that the penalties for non-redistributable modules and modules without
// go.mod files are applied correctly.
t.Parallel()
testDB, release := acquire(t)
defer release()
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
// All these modules will have the same text ranking for the search term "foo",
// but different scores due to penalties.
modules := map[string]struct {
redist bool
hasGoMod bool
multiplier float64 // applied to base score
}{
"both.com/foo": {true, true, 1},
"nogomod.com/foo": {true, false, noGoModPenalty},
"nonredist.com/foo": {false, true, nonRedistributablePenalty},
"neither.com/foo": {false, false, noGoModPenalty * nonRedistributablePenalty},
}
for path, m := range modules {
v := sample.Module(path, sample.VersionString, "p")
v.Packages()[0].IsRedistributable = m.redist
v.IsRedistributable = m.redist
v.HasGoMod = m.hasGoMod
MustInsertModule(ctx, t, testDB, v)
}
for method, searcher := range pkgSearchers {
t.Run(method, func(t *testing.T) {
res := searcher(testDB, ctx, "foo", 10, 0, 100)
if res.err != nil {
t.Fatal(res.err)
}
if got, want := len(res.results), len(modules); got != want {
t.Fatalf("got %d search results, want %d", got, want)
}
for _, r := range res.results {
got := r.Score
want := res.results[0].Score * modules[r.ModulePath].multiplier
if math.Abs(got-want) > 1e6 {
t.Errorf("%s: got %f, want %f", r.ModulePath, got, want)
}
}
})
}
}
func TestExcludedFromSearch(t *testing.T) {
// Verify that excluded paths are omitted from search results.
t.Parallel()
testDB, release := acquire(t)
defer release()
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
// Insert a module with two packages.
const domain = "exclude.com"
sm := sample.Module(domain, "v1.2.3", "pkg", "ex/clude")
MustInsertModule(ctx, t, testDB, sm)
// Exclude a prefix that matches one of the packages.
if err := testDB.InsertExcludedPrefix(ctx, domain+"/ex", "no user", "no reason"); err != nil {
t.Fatal(err)
}
// Search for both packages.
gotResults, err := testDB.Search(ctx, domain, SearchOptions{MaxResults: 10})
if err != nil {
t.Fatal(err)
}
var got []string
for _, g := range gotResults {
got = append(got, g.Name)
}
want := []string{"pkg"}
if !cmp.Equal(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestSearchBypass(t *testing.T) {
t.Parallel()
testDB, release := acquire(t)
defer release()
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
bypassDB := NewBypassingLicenseCheck(testDB.db)
m := nonRedistributableModule()
MustInsertModule(ctx, t, bypassDB, m)
for _, test := range []struct {
db *DB
wantEmpty bool
}{
{testDB, true},
{bypassDB, false},
} {
rs, err := test.db.Search(ctx, m.ModulePath, SearchOptions{MaxResults: 10})
if err != nil {
t.Fatal(err)
}
if got := (rs[0].Synopsis == ""); got != test.wantEmpty {
t.Errorf("bypass %t: got empty %t, want %t", test.db == bypassDB, got, test.wantEmpty)
}
}
}
func TestSearchLicenseDedup(t *testing.T) {
// Verify that a license type appears only once even if there are multiple
// licenses of that type.
t.Parallel()
testDB, release := acquire(t)
defer release()
ctx := context.Background()
m := sample.Module("example.com/mod", "v1.2.3", "pkg")
// Add a second MIT license in the pkg directory.
sample.AddLicense(m, &licenses.License{
Metadata: &licenses.Metadata{
Types: []string{"MIT"},
FilePath: "pkg/LICENSE.md",
},
})
MustInsertModule(ctx, t, testDB, m)
got, err := testDB.Search(ctx, m.ModulePath, SearchOptions{MaxResults: 10})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("got %d results, want 1", len(got))
}
if got, want := got[0].Licenses, []string{"MIT"}; !cmp.Equal(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
type searchDocument struct {
packagePath string
modulePath string
version string
commitTime time.Time
name string
synopsis string
licenseTypes []string
importedByCount int
redistributable bool
hasGoMod bool
versionUpdatedAt time.Time
importedByCountUpdatedAt time.Time
}
// getSearchDocument returns the search_document for the package with the given
// path. It is only used for testing purposes.
func getSearchDocument(ctx context.Context, db *DB, path string) (*searchDocument, error) {
query := `
SELECT
package_path,
module_path,
version,
commit_time,
name,
synopsis,
license_types,
imported_by_count,
redistributable,
has_go_mod,
version_updated_at,
imported_by_count_updated_at
FROM
search_documents
WHERE package_path=$1`
row := db.db.QueryRow(ctx, query, path)
var (
sd searchDocument
t pq.NullTime
)
if err := row.Scan(&sd.packagePath, &sd.modulePath, &sd.version, &sd.commitTime,
&sd.name, &sd.synopsis, pq.Array(&sd.licenseTypes), &sd.importedByCount,
&sd.redistributable, &sd.hasGoMod, &sd.versionUpdatedAt, &t); err != nil {
return nil, fmt.Errorf("row.Scan(): %v", err)
}
if t.Valid {
sd.importedByCountUpdatedAt = t.Time
}
return &sd, nil
}
func TestUpsertSearchDocument(t *testing.T) {
// Verify that inserting into search_documents populates all columns correctly,
// both with and without a conflict.
t.Parallel()
testDB, release := acquire(t)
defer release()
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
var packagePath = sample.ModulePath + "/A"
getSearchDocument := func() *searchDocument {
t.Helper()
sd, err := getSearchDocument(ctx, testDB, packagePath)
if err != nil {
t.Fatal(err)
}
return sd
}
insertModule := func(version string, gomod, latest bool) {
m := sample.Module(sample.ModulePath, version, "A")
m.HasGoMod = gomod
m.Packages()[0].Documentation[0].Synopsis = "syn-" + version
if latest {
MustInsertModule(ctx, t, testDB, m)
} else {
MustInsertModuleNotLatest(ctx, t, testDB, m)
}
}
const v1 = "v1.0.0"
insertModule(v1, false, true)
sd1 := getSearchDocument()
if sd1.version != v1 {
t.Fatalf("got %s, want %s", sd1.version, v1)
}
// Since the latest compatible version has no go.mod file, this incompatible version
// is the latest.
const vInc = "v2.0.0+incompatible"
insertModule(vInc, false, true)
sdInc := getSearchDocument()
if sdInc.version != vInc {
t.Fatalf("got %s, want %s", sdInc.version, vInc)
}
// Inserting an older module doesn't change anything.
insertModule("v0.5.0", true, false)
sdOlder := getSearchDocument()
if diff := cmp.Diff(sdInc, sdOlder, cmp.AllowUnexported(searchDocument{})); diff != "" {
t.Fatalf("mismatch (-want, +got):\n%s", diff)
}
// A later compatible version with a go.mod file. This becomes the new latest version.
const v15 = "v1.5.2"
insertModule(v15, true, true)
sdNewer := getSearchDocument()
if sdNewer.version != v15 {
t.Fatalf("got %s, want %s", sdNewer.version, v15)
}
sdWant := sd1
sdWant.version = "v1.5.2"
sdWant.synopsis = "syn-v1.5.2"
sdWant.hasGoMod = true
sdWant.versionUpdatedAt = sdNewer.versionUpdatedAt
if diff := cmp.Diff(sdWant, sdNewer, cmp.AllowUnexported(searchDocument{})); diff != "" {
t.Errorf("mismatch (-want, +got):\n%s", diff)
}
}
func TestUpsertSearchDocumentVersionHasGoMod(t *testing.T) {
t.Parallel()
testDB, release := acquire(t)
defer release()
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
for _, hasGoMod := range []bool{true, false} {
m := sample.Module(fmt.Sprintf("foo.com/%t", hasGoMod), "v1.2.3", "bar")
m.HasGoMod = hasGoMod
MustInsertModule(ctx, t, testDB, m)
}
for _, hasGoMod := range []bool{true, false} {
packagePath := fmt.Sprintf("foo.com/%t/bar", hasGoMod)
sd, err := getSearchDocument(ctx, testDB, packagePath)
if err != nil {
t.Fatalf("testDB.getSearchDocument(ctx, %q): %v", packagePath, err)
}
if sd.hasGoMod != hasGoMod {
t.Errorf("got hasGoMod=%t want %t", sd.hasGoMod, hasGoMod)
}
}
}
func TestUpdateSearchDocumentsImportedByCount(t *testing.T) {
t.Parallel()
ctx := context.Background()
pkgPath := func(m *internal.Module) string { return m.Packages()[0].Path }
// insert package with suffix at version, return the module
insertPackageVersion := func(t *testing.T, db *DB, suffix, version string, imports []string) *internal.Module {
t.Helper()
m := sample.Module("mod.com/"+suffix, version, suffix)
// Units[0] is the module itself.
pkg := m.Units[1]
pkg.Imports = nil
for _, imp := range imports {
pkg.Imports = append(pkg.Imports, fmt.Sprintf("mod.com/%s/%[1]s", imp))
}
MustInsertModule(ctx, t, db, m)
return m
}
updateImportedByCount := func(db *DB) {
t.Helper()
if _, err := db.UpdateSearchDocumentsImportedByCount(ctx); err != nil {
t.Fatal(err)
}
}
validateImportedByCountAndGetSearchDocument := func(t *testing.T, db *DB, path string, count int) *searchDocument {
t.Helper()
sd, err := getSearchDocument(ctx, db, path)
if err != nil {
t.Fatalf("testDB.getSearchDocument(ctx, %q): %v", path, err)
}
if sd.importedByCount > 0 && sd.importedByCountUpdatedAt.IsZero() {
t.Fatalf("importedByCountUpdatedAt for package %q should not be empty if count > 0", path)
}
if count != sd.importedByCount {
t.Fatalf("importedByCount for package %q = %d; want = %d", path, sd.importedByCount, count)
}
return sd
}
t.Run("basic", func(t *testing.T) {
testDB, release := acquire(t)
defer release()
// Test imported_by_count = 0 when only pkgA is added.
mA := insertPackageVersion(t, testDB, "A", "v1.0.0", nil)
updateImportedByCount(testDB)
_ = validateImportedByCountAndGetSearchDocument(t, testDB, pkgPath(mA), 0)
// Test imported_by_count = 1 for pkgA when pkgB is added.
mB := insertPackageVersion(t, testDB, "B", "v1.0.0", []string{"A"})
updateImportedByCount(testDB)
_ = validateImportedByCountAndGetSearchDocument(t, testDB, pkgPath(mA), 1)
sdB := validateImportedByCountAndGetSearchDocument(t, testDB, pkgPath(mB), 0)
wantSearchDocBUpdatedAt := sdB.importedByCountUpdatedAt
// Test imported_by_count = 2 for pkgA, when C is added.
mC := insertPackageVersion(t, testDB, "C", "v1.0.0", []string{"A"})
updateImportedByCount(testDB)
sdA := validateImportedByCountAndGetSearchDocument(t, testDB, pkgPath(mA), 2)
sdC := validateImportedByCountAndGetSearchDocument(t, testDB, pkgPath(mC), 0)
// Nothing imports C, so it has never been updated.
if !sdC.importedByCountUpdatedAt.IsZero() {
t.Fatalf("pkgC imported_by_count_updated_at should be zero, but is %v", sdC.importedByCountUpdatedAt)
}
if sdA.importedByCountUpdatedAt.IsZero() {
t.Fatal("pkgA imported_by_count_updated_at should be non-zero, but is zero")
}
// Test imported_by_count_updated_at for B has not changed.
sdB = validateImportedByCountAndGetSearchDocument(t, testDB, pkgPath(mB), 0)
if sdB.importedByCountUpdatedAt != wantSearchDocBUpdatedAt {
t.Fatalf("expected imported_by_count_updated_at for pkgB not to have changed; old = %v, new = %v",
wantSearchDocBUpdatedAt, sdB.importedByCountUpdatedAt)
}
// When an older version of A imports D, nothing happens to the counts,
// because imports_unique only records the latest version of each package.
mD := insertPackageVersion(t, testDB, "D", "v1.0.0", nil)
insertPackageVersion(t, testDB, "A", "v0.9.0", []string{"D"})
updateImportedByCount(testDB)
_ = validateImportedByCountAndGetSearchDocument(t, testDB, pkgPath(mA), 2)
_ = validateImportedByCountAndGetSearchDocument(t, testDB, pkgPath(mD), 0)
// When a newer version of A imports D, however, the counts do change.
insertPackageVersion(t, testDB, "A", "v1.1.0", []string{"D"})
updateImportedByCount(testDB)
_ = validateImportedByCountAndGetSearchDocument(t, testDB, pkgPath(mA), 2)
_ = validateImportedByCountAndGetSearchDocument(t, testDB, pkgPath(mD), 1)
})
t.Run("alternative", func(t *testing.T) {
// Test with alternative modules that are removed from search_documents.
testDB, release := acquire(t)
defer release()
insertPackageVersion(t, testDB, "B", "v1.0.0", nil)
// Insert a package with the canonical module path.
canonicalModule := insertPackageVersion(t, testDB, "A", "v1.0.0", []string{"B"})
// Imagine we see a package with an alternative path at v1.2.0.
// We add that information to module_version_states.
alternativeModulePath := strings.ToLower(canonicalModule.ModulePath)
alternativeStatus := derrors.ToStatus(derrors.AlternativeModule)
mvs := &ModuleVersionStateForUpsert{
ModulePath: alternativeModulePath,
Version: "v1.2.0",
Timestamp: time.Now(),
Status: alternativeStatus,
GoModPath: canonicalModule.ModulePath,
}
if err := testDB.UpsertModuleVersionState(ctx, mvs); err != nil {
t.Fatal(err)
}
// Now we see an earlier version of that package, without a go.mod file, so we insert it.
// It should not get inserted into search_documents.
mAlt := sample.Module(alternativeModulePath, "v1.0.0", "A")
mAlt.Packages()[0].Imports = []string{"B"}
MustInsertModule(ctx, t, testDB, mAlt)
// Although B is imported by two packages, only one is in search_documents, so its
// imported-by count is 1.
updateImportedByCount(testDB)
validateImportedByCountAndGetSearchDocument(t, testDB, "mod.com/B/B", 1)
})
}
func TestGetPackagesForSearchDocumentUpsert(t *testing.T) {
t.Parallel()
testDB, release := acquire(t)
defer release()
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
moduleA := sample.Module("mod.com", "v1.2.3",
"A", "A/notinternal", "A/internal", "A/internal/B")
// moduleA.Units[1] is mod.com/A.
moduleA.Units[1].Readme = &internal.Readme{
Filepath: sample.ReadmeFilePath,
Contents: sample.ReadmeContents,
}
// moduleA.Units[2] is mod.com/A/notinternal.
moduleA.Units[2].Readme = &internal.Readme{
Filepath: sample.ReadmeFilePath,
Contents: sample.ReadmeContents,
}
moduleN := nonRedistributableModule()
bypassDB := NewBypassingLicenseCheck(testDB.db)
for _, m := range []*internal.Module{moduleA, moduleN} {
MustInsertModule(ctx, t, bypassDB, m)
}
// We are asking for all packages in search_documents updated before now, which is
// all the non-internal packages.
got, err := testDB.GetPackagesForSearchDocumentUpsert(ctx, time.Now(), 10)
if err != nil {
t.Fatal(err)
}
sort.Slice(got, func(i, j int) bool { return got[i].PackagePath < got[j].PackagePath })
want := []UpsertSearchDocumentArgs{
{
PackagePath: moduleN.ModulePath,
ModulePath: moduleN.ModulePath,
Version: "v1.2.3",
ReadmeFilePath: "",
ReadmeContents: "",
Synopsis: "",
},
{
PackagePath: "mod.com/A",
ModulePath: "mod.com",
Version: "v1.2.3",
ReadmeFilePath: "README.md",
ReadmeContents: "readme",
Synopsis: sample.Doc.Synopsis,
},
{
PackagePath: "mod.com/A/notinternal",
ModulePath: "mod.com",
Version: "v1.2.3",
ReadmeFilePath: "README.md",
ReadmeContents: "readme",
Synopsis: sample.Doc.Synopsis,
},
}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("testDB.GetPackagesForSearchDocumentUpsert mismatch(-want +got):\n%s", diff)
}
// Reading with license bypass should return the non-redistributable fields.
got, err = bypassDB.GetPackagesForSearchDocumentUpsert(ctx, time.Now(), 10)
if err != nil {
t.Fatal(err)
}
if len(got) == 0 {
t.Fatal("len(got)==0")
}
sort.Slice(got, func(i, j int) bool { return got[i].PackagePath < got[j].PackagePath })
gm := got[0]
for _, got := range []string{gm.ReadmeFilePath, gm.ReadmeContents, gm.Synopsis} {
if got == "" {
t.Errorf("got empty field, want non-empty")
}
}
// pkgPaths should be an empty slice, all packages were inserted more recently than yesterday.
got, err = testDB.GetPackagesForSearchDocumentUpsert(ctx, time.Now().Add(-24*time.Hour), 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("expected testDB.GetPackagesForSearchDocumentUpsert to return an empty slice; got %v", got)
}
}
func TestHllHash(t *testing.T) {
t.Parallel()
testDB, release := acquire(t)
defer release()
tests := []string{
"",
"The lazy fox.",
"golang.org/x/tools",
"Hello, 世界",
}
for _, test := range tests {
h := md5.New()
io.WriteString(h, test)
want := int64(binary.BigEndian.Uint64(h.Sum(nil)[0:8]))
row := testDB.db.QueryRow(context.Background(), "SELECT hll_hash($1)", test)
var got int64
if err := row.Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("hll_hash(%q) = %d, want %d", test, got, want)
}
}
}
func TestHllZeros(t *testing.T) {
t.Parallel()
testDB, release := acquire(t)
defer release()
tests := []struct {
i int64
want int
}{
{-1, 0},
{-(1 << 63), 0},
{0, 64},
{1, 63},
{1 << 31, 32},
{1 << 62, 1},
{(1 << 63) - 1, 1},
}
for _, test := range tests {
row := testDB.db.QueryRow(context.Background(), "SELECT hll_zeros($1)", test.i)
var got int
if err := row.Scan(&got); err != nil {
t.Fatal(err)
}
if got != test.want {
t.Errorf("hll_zeros(%d) = %d, want %d", test.i, got, test.want)
}
}
}
func TestDeleteFromSearch(t *testing.T) {
t.Parallel()
ctx := context.Background()
const modulePath = "deleteme.com"
initial := []searchDocumentRow{
{modulePath + "/p1", modulePath, "v0.0.9"}, // oldest version of same module
{modulePath + "/p2", modulePath, "v1.1.0"}, // older version of same module
{modulePath + "/p4", modulePath, "v1.9.0"}, // newer version of same module
{"other.org/p2", "other.org", "v1.1.0"}, // older version of a different module
}
insertInitial := func(db *DB) {
t.Helper()
for _, r := range initial {
sm := sample.Module(r.ModulePath, r.Version, strings.TrimPrefix(r.PackagePath, r.ModulePath+"/"))
MustInsertModule(ctx, t, db, sm)
}
// Older versions are deleted by InsertModule, so only the newest versions are here.
checkSearchDocuments(ctx, t, db, initial[2:])
}
t.Run("DeleteOlderVersionFromSearchDocuments", func(t *testing.T) {
testDB, release := acquire(t)
defer release()
insertInitial(testDB)
if err := testDB.DeleteOlderVersionFromSearchDocuments(ctx, modulePath, "v1.2.3"); err != nil {
t.Fatal(err)
}
checkSearchDocuments(ctx, t, testDB, []searchDocumentRow{
{modulePath + "/p4", modulePath, "v1.9.0"}, // newer version not deleted
{"other.org/p2", "other.org", "v1.1.0"}, // other module not deleted
})
})
t.Run("deleteModuleFromSearchDocuments", func(t *testing.T) {
// Non-empty list of packages tested above. This tests an empty list.
testDB, release := acquire(t)
defer release()
insertInitial(testDB)
if err := deleteModuleFromSearchDocuments(ctx, testDB.db, modulePath, nil); err != nil {
t.Fatal(err)
}
checkSearchDocuments(ctx, t, testDB, []searchDocumentRow{
{"other.org/p2", "other.org", "v1.1.0"}, // other module not deleted
})
})
}
type searchDocumentRow struct {
PackagePath, ModulePath, Version string
}
func readSearchDocuments(ctx context.Context, db *DB) ([]searchDocumentRow, error) {
var rows []searchDocumentRow
err := db.db.CollectStructs(ctx, &rows, `SELECT package_path, module_path, version FROM search_documents`)
if err != nil {
return nil, err
}
return rows, err
}
func checkSearchDocuments(ctx context.Context, t *testing.T, db *DB, want []searchDocumentRow) {
t.Helper()
got, err := readSearchDocuments(ctx, db)
if err != nil {
t.Fatal(err)
}
less := func(r1, r2 searchDocumentRow) bool {
if r1.PackagePath != r2.PackagePath {
return r1.PackagePath < r2.PackagePath
}
if r1.ModulePath != r2.ModulePath {
return r1.ModulePath < r2.ModulePath
}
return r1.Version < r2.Version
}
sort.Slice(got, func(i, j int) bool { return less(got[i], got[j]) })
sort.Slice(want, func(i, j int) bool { return less(want[i], want[j]) })
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("search documents mismatch (-want, +got):\n%s", diff)
}
}
func TestGroupSearchResults(t *testing.T) {
rs := []*SearchResult{
{PackagePath: "m.com/p", ModulePath: "m.com", Score: 10},
{PackagePath: "m.com/p2", ModulePath: "m.com", Score: 8},
{PackagePath: "a.com/p", ModulePath: "a.com", Score: 7},
{PackagePath: "m.com/v2/p", ModulePath: "m.com/v2", Score: 6},
{PackagePath: "m.com/v2/p2", ModulePath: "m.com/v2", Score: 4},
}
got := groupSearchResults(rs)
sp2 := &SearchResult{
PackagePath: "m.com/p2",
ModulePath: "m.com",
Score: 8,
}
sp := &SearchResult{
PackagePath: "m.com/p",
ModulePath: "m.com",
Score: 10,
SameModule: []*SearchResult{sp2},
}
want := []*SearchResult{
{
PackagePath: "m.com/v2/p",
ModulePath: "m.com/v2",
Score: 10.00001,
SameModule: []*SearchResult{
{PackagePath: "m.com/v2/p2", ModulePath: "m.com/v2", Score: 4},
},
OtherMajor: map[string]bool{"m.com": true},
},
sp,
{
PackagePath: "a.com/p",
ModulePath: "a.com",
Score: 7,
OtherMajor: map[string]bool{},
},
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("mismatch (-want, +got)\n%s", diff)
}
}