perkeep/pkg/search/query_test.go

2256 lines
56 KiB
Go

/*
Copyright 2013 The Perkeep Authors
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 search_test
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"flag"
"fmt"
"image"
"image/color"
"image/jpeg"
"image/png"
"io/ioutil"
"log"
"math/rand"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"sync"
"testing"
"time"
"go4.org/types"
"perkeep.org/internal/geocode"
"perkeep.org/internal/osutil"
"perkeep.org/pkg/blob"
"perkeep.org/pkg/index"
"perkeep.org/pkg/index/indextest"
. "perkeep.org/pkg/search"
"perkeep.org/pkg/test"
"perkeep.org/pkg/types/camtypes"
)
var ctxbg = context.Background()
// indexType is one of the three ways we test the query handler code.
type indexType int
var queryType = flag.String("querytype", "", "Empty for all query types, else 'classic', 'scan', or 'build'")
const (
indexClassic indexType = iota // sorted key/value pairs from index.Storage
indexCorpusScan // *Corpus scanned from key/value pairs on start
indexCorpusBuild // empty *Corpus, built iteratively as blob received.
)
var (
allIndexTypes = []indexType{indexClassic, indexCorpusScan, indexCorpusBuild}
memIndexTypes = []indexType{indexCorpusScan, indexCorpusBuild}
corpusTypeOnly = []indexType{indexCorpusScan}
)
func (i indexType) String() string {
switch i {
case indexClassic:
return "classic"
case indexCorpusScan:
return "scan"
case indexCorpusBuild:
return "build"
default:
return fmt.Sprintf("unknown-index-type-%d", i)
}
}
type queryTest struct {
t testing.TB
id *indextest.IndexDeps
itype indexType
candidateSource string
handlerOnce sync.Once
newHandler func() *Handler
handler *Handler // initialized with newHandler
// set by wantRes if the query was successful, so we can examine some extra
// query's results after wantRes is called. nil otherwise.
res *SearchResult
}
func (qt *queryTest) Handler() *Handler {
qt.handlerOnce.Do(func() { qt.handler = qt.newHandler() })
return qt.handler
}
func testQuery(t testing.TB, fn func(*queryTest)) {
testQueryTypes(t, allIndexTypes, fn)
}
func testQueryTypes(t testing.TB, types []indexType, fn func(*queryTest)) {
defer test.TLog(t)()
for _, it := range types {
if *queryType == "" || *queryType == it.String() {
t.Logf("Testing: --querytype=%s ...", it)
testQueryType(t, fn, it)
}
}
}
func testQueryType(t testing.TB, fn func(*queryTest), itype indexType) {
defer index.SetVerboseCorpusLogging(true)
index.SetVerboseCorpusLogging(false)
idx := index.NewMemoryIndex() // string key-value pairs in memory, as if they were on disk
var err error
var corpus *index.Corpus
if itype == indexCorpusBuild {
if corpus, err = idx.KeepInMemory(); err != nil {
t.Fatal(err)
}
}
qt := &queryTest{
t: t,
id: indextest.NewIndexDeps(idx),
itype: itype,
}
qt.id.Fataler = t
qt.newHandler = func() *Handler {
h := NewHandler(idx, owner)
if itype == indexCorpusScan {
if corpus, err = idx.KeepInMemory(); err != nil {
t.Fatal(err)
}
idx.PreventStorageAccessForTesting()
}
if corpus != nil {
h.SetCorpus(corpus)
}
return h
}
fn(qt)
}
func (qt *queryTest) wantRes(req *SearchQuery, wanted ...blob.Ref) {
if qt.itype == indexClassic {
req.Sort = Unsorted
}
if qt.candidateSource != "" {
ExportSetCandidateSourceHook(func(pickedCandidate string) {
if pickedCandidate != qt.candidateSource {
qt.t.Fatalf("unexpected candidateSource: got %v, want %v", pickedCandidate, qt.candidateSource)
}
})
}
res, err := qt.Handler().Query(ctxbg, req)
if err != nil {
qt.t.Fatal(err)
}
qt.res = res
need := make(map[blob.Ref]bool)
for _, br := range wanted {
need[br] = true
}
for _, bi := range res.Blobs {
if !need[bi.Blob] {
qt.t.Errorf("unexpected search result: %v", bi.Blob)
} else {
delete(need, bi.Blob)
}
}
for br := range need {
qt.t.Errorf("missing from search result: %v", br)
}
}
func TestQuery(t *testing.T) {
testQuery(t, func(qt *queryTest) {
fileRef, wholeRef := qt.id.UploadFile("file.txt", "the content", time.Unix(1382073153, 0))
sq := &SearchQuery{
Constraint: &Constraint{
Anything: true,
},
Limit: 0,
Sort: UnspecifiedSort,
}
qt.wantRes(sq, fileRef, wholeRef)
})
}
func TestQueryCamliType(t *testing.T) {
testQuery(t, func(qt *queryTest) {
fileRef, _ := qt.id.UploadFile("file.txt", "foo", time.Unix(1382073153, 0))
sq := &SearchQuery{
Constraint: &Constraint{
CamliType: "file",
},
}
qt.wantRes(sq, fileRef)
})
}
func TestQueryAnyCamliType(t *testing.T) {
testQuery(t, func(qt *queryTest) {
fileRef, _ := qt.id.UploadFile("file.txt", "foo", time.Unix(1382073153, 0))
sq := &SearchQuery{
Constraint: &Constraint{
AnyCamliType: true,
},
}
qt.wantRes(sq, fileRef)
})
}
func TestQueryBlobSize(t *testing.T) {
testQuery(t, func(qt *queryTest) {
_, smallFileRef := qt.id.UploadFile("file.txt", strings.Repeat("x", 5<<10), time.Unix(1382073153, 0))
qt.id.UploadFile("file.txt", strings.Repeat("x", 20<<10), time.Unix(1382073153, 0))
sq := &SearchQuery{
Constraint: &Constraint{
BlobSize: &IntConstraint{
Min: 4 << 10,
Max: 6 << 10,
},
},
}
qt.wantRes(sq, smallFileRef)
})
}
func TestQueryBlobRefPrefix(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
// foo is 0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33
id.UploadFile("file.txt", "foo", time.Unix(1382073153, 0))
// "bar.." is 08ef767ba2c93f8f40902118fa5260a65a2a4975
id.UploadFile("file.txt", "bar..", time.Unix(1382073153, 0))
sq := &SearchQuery{
Constraint: &Constraint{
BlobRefPrefix: "sha1-0",
},
}
sres, err := qt.Handler().Query(ctxbg, sq)
if err != nil {
t.Fatal(err)
}
if len(sres.Blobs) < 2 {
t.Errorf("expected at least 2 matches; got %d", len(sres.Blobs))
}
for _, res := range sres.Blobs {
brStr := res.Blob.String()
if !strings.HasPrefix(brStr, "sha1-0") {
t.Errorf("matched blob %s didn't begin with sha1-0", brStr)
}
}
})
}
func TestQueryTwoConstraints(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
id.UploadString("a") // 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8
b := id.UploadString("b") // e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98
id.UploadString("c4") // e4666a670f042877c67a84473a71675ee0950a08
sq := &SearchQuery{
Constraint: &Constraint{
BlobRefPrefix: "sha1-e", // matches b and c4
BlobSize: &IntConstraint{ // matches a and b
Min: 1,
Max: 1,
},
},
}
qt.wantRes(sq, b)
})
}
func TestQueryLogicalOr(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
// foo is 0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33
_, foo := id.UploadFile("file.txt", "foo", time.Unix(1382073153, 0))
// "bar.." is 08ef767ba2c93f8f40902118fa5260a65a2a4975
_, bar := id.UploadFile("file.txt", "bar..", time.Unix(1382073153, 0))
sq := &SearchQuery{
Constraint: &Constraint{
Logical: &LogicalConstraint{
Op: "or",
A: &Constraint{
BlobRefPrefix: "sha1-0beec7b5ea3f0fdbc95d0dd",
},
B: &Constraint{
BlobRefPrefix: "sha1-08ef767ba2c93f8f40",
},
},
},
}
qt.wantRes(sq, foo, bar)
})
}
func TestQueryLogicalAnd(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
// foo is 0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33
_, foo := id.UploadFile("file.txt", "foo", time.Unix(1382073153, 0))
// "bar.." is 08ef767ba2c93f8f40902118fa5260a65a2a4975
id.UploadFile("file.txt", "bar..", time.Unix(1382073153, 0))
sq := &SearchQuery{
Constraint: &Constraint{
Logical: &LogicalConstraint{
Op: "and",
A: &Constraint{
BlobRefPrefix: "sha1-0",
},
B: &Constraint{
BlobSize: &IntConstraint{
Max: int64(len("foo")), // excludes "bar.."
},
},
},
},
}
qt.wantRes(sq, foo)
})
}
func TestQueryLogicalXor(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
// foo is 0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33
_, foo := id.UploadFile("file.txt", "foo", time.Unix(1382073153, 0))
// "bar.." is 08ef767ba2c93f8f40902118fa5260a65a2a4975
id.UploadFile("file.txt", "bar..", time.Unix(1382073153, 0))
sq := &SearchQuery{
Constraint: &Constraint{
Logical: &LogicalConstraint{
Op: "xor",
A: &Constraint{
BlobRefPrefix: "sha1-0",
},
B: &Constraint{
BlobRefPrefix: "sha1-08ef767ba2c93f8f40",
},
},
},
}
qt.wantRes(sq, foo)
})
}
func TestQueryLogicalNot(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
// foo is 0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33
_, foo := id.UploadFile("file.txt", "foo", time.Unix(1382073153, 0))
// "bar.." is 08ef767ba2c93f8f40902118fa5260a65a2a4975
_, bar := id.UploadFile("file.txt", "bar..", time.Unix(1382073153, 0))
sq := &SearchQuery{
Constraint: &Constraint{
Logical: &LogicalConstraint{
Op: "not",
A: &Constraint{
CamliType: "file",
},
},
},
}
qt.wantRes(sq, foo, bar)
})
}
func TestQueryPermanodeAttrExact(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
p1 := id.NewPlannedPermanode("1")
p2 := id.NewPlannedPermanode("2")
id.SetAttribute(p1, "someAttr", "value1")
id.SetAttribute(p2, "someAttr", "value2")
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "someAttr",
Value: "value1",
},
},
}
qt.wantRes(sq, p1)
})
}
func TestQueryPermanodeAttrMatches(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
p1 := id.NewPlannedPermanode("1")
p2 := id.NewPlannedPermanode("2")
p3 := id.NewPlannedPermanode("3")
id.SetAttribute(p1, "someAttr", "value1")
id.SetAttribute(p2, "someAttr", "value2")
id.SetAttribute(p3, "someAttr", "NOT starting with value")
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "someAttr",
ValueMatches: &StringConstraint{
HasPrefix: "value",
},
},
},
}
qt.wantRes(sq, p1, p2)
})
}
func TestQueryPermanodeAttrNumValue(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
// TODO(bradfitz): if we set an empty attribute value here and try to search
// by NumValue IntConstraint Min = 1, it fails only in classic (no corpus) mode.
// Something there must be skipping empty values.
p1 := id.NewPlannedPermanode("1")
id.AddAttribute(p1, "x", "1")
id.AddAttribute(p1, "x", "2")
p2 := id.NewPlannedPermanode("2")
id.AddAttribute(p2, "x", "1")
id.AddAttribute(p2, "x", "2")
id.AddAttribute(p2, "x", "3")
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "x",
NumValue: &IntConstraint{
Min: 3,
},
},
},
}
qt.wantRes(sq, p2)
})
}
// Tests that NumValue queries with ZeroMax return permanodes without any values.
func TestQueryPermanodeAttrNumValueZeroMax(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
p1 := id.NewPlannedPermanode("1")
id.AddAttribute(p1, "x", "1")
p2 := id.NewPlannedPermanode("2")
id.AddAttribute(p2, "y", "1") // Permanodes without any attributes are ignored.
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "x",
NumValue: &IntConstraint{
ZeroMax: true,
},
},
},
}
qt.wantRes(sq, p2)
})
}
// find a permanode (p2) that has a property being a blobref pointing
// to a sub-query
func TestQueryPermanodeAttrValueInSet(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
p1 := id.NewPlannedPermanode("1")
id.SetAttribute(p1, "bar", "baz")
p2 := id.NewPlannedPermanode("2")
id.SetAttribute(p2, "foo", p1.String())
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "foo",
ValueInSet: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "bar",
Value: "baz",
},
},
},
},
}
qt.wantRes(sq, p2)
})
}
// Tests PermanodeConstraint.ValueMatchesInt.
func TestQueryPermanodeValueMatchesInt(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
p1 := id.NewPlannedPermanode("1")
p2 := id.NewPlannedPermanode("2")
p3 := id.NewPlannedPermanode("3")
p4 := id.NewPlannedPermanode("4")
p5 := id.NewPlannedPermanode("5")
id.SetAttribute(p1, "x", "-5")
id.SetAttribute(p2, "x", "0")
id.SetAttribute(p3, "x", "2")
id.SetAttribute(p4, "x", "10.0")
id.SetAttribute(p5, "x", "abc")
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "x",
ValueMatchesInt: &IntConstraint{
Min: -2,
},
},
},
}
qt.wantRes(sq, p2, p3)
})
}
// Tests PermanodeConstraint.ValueMatchesFloat.
func TestQueryPermanodeValueMatchesFloat(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
p1 := id.NewPlannedPermanode("1")
p2 := id.NewPlannedPermanode("2")
p3 := id.NewPlannedPermanode("3")
p4 := id.NewPlannedPermanode("4")
id.SetAttribute(p1, "x", "2.5")
id.SetAttribute(p2, "x", "5.7")
id.SetAttribute(p3, "x", "10")
id.SetAttribute(p4, "x", "abc")
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "x",
ValueMatchesFloat: &FloatConstraint{
Max: 6.0,
},
},
},
}
qt.wantRes(sq, p1, p2)
})
}
func TestQueryPermanodeLocation(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
p1 := id.NewPlannedPermanode("1")
p2 := id.NewPlannedPermanode("2")
p3 := id.NewPlannedPermanode("3")
id.SetAttribute(p1, "latitude", "51.5")
id.SetAttribute(p1, "longitude", "0")
id.SetAttribute(p2, "latitude", "51.5")
id.SetAttribute(p3, "longitude", "0")
p4 := id.NewPlannedPermanode("checkin")
p5 := id.NewPlannedPermanode("venue")
id.SetAttribute(p4, "camliNodeType", "foursquare.com:checkin")
id.SetAttribute(p4, "foursquareVenuePermanode", p5.String())
id.SetAttribute(p5, "latitude", "1.0")
id.SetAttribute(p5, "longitude", "2.0")
// Upload a basic image
camliRootPath, err := osutil.GoPackagePath("perkeep.org")
if err != nil {
panic("Package perkeep.org not found in $GOPATH or $GOPATH not defined")
}
uploadFile := func(file string, modTime time.Time) blob.Ref {
fileName := filepath.Join(camliRootPath, "pkg", "search", "testdata", file)
contents, err := ioutil.ReadFile(fileName)
if err != nil {
panic(err)
}
br, _ := id.UploadFile(file, string(contents), modTime)
return br
}
fileRef := uploadFile("dude-gps.jpg", time.Time{})
p6 := id.NewPlannedPermanode("photo")
id.SetAttribute(p6, "camliContent", fileRef.String())
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Location: &LocationConstraint{
Any: true,
},
},
},
}
qt.wantRes(sq, p1, p4, p5, p6)
})
}
func TestQueryFileLocation(t *testing.T) {
testQueryTypes(t, memIndexTypes, func(qt *queryTest) {
id := qt.id
// Upload a basic image
camliRootPath, err := osutil.GoPackagePath("perkeep.org")
if err != nil {
panic("Package perkeep.org not found in $GOPATH or $GOPATH not defined")
}
uploadFile := func(file string, modTime time.Time) blob.Ref {
fileName := filepath.Join(camliRootPath, "pkg", "search", "testdata", file)
contents, err := ioutil.ReadFile(fileName)
if err != nil {
panic(err)
}
br, _ := id.UploadFile(file, string(contents), modTime)
return br
}
fileRef := uploadFile("dude-gps.jpg", time.Time{})
p6 := id.NewPlannedPermanode("photo")
id.SetAttribute(p6, "camliContent", fileRef.String())
sq := &SearchQuery{
Constraint: &Constraint{
File: &FileConstraint{
Location: &LocationConstraint{
Any: true,
},
},
},
}
qt.wantRes(sq, fileRef)
if qt.res == nil {
t.Fatal("No results struct")
}
if qt.res.LocationArea == nil {
t.Fatal("No location area in results")
}
want := camtypes.LocationBounds{
North: 42.45,
South: 42.45,
West: 18.76,
East: 18.76,
}
if *qt.res.LocationArea != want {
t.Fatalf("Wrong location area expansion: wanted %#v, got %#v", want, *qt.res.LocationArea)
}
ExportSetExpandLocationHook(true)
qt.wantRes(sq)
if qt.res == nil {
t.Fatal("No results struct")
}
if qt.res.LocationArea != nil {
t.Fatalf("Location area should not have been expanded")
}
ExportSetExpandLocationHook(false)
})
}
// find permanodes matching a certain file query
func TestQueryFileConstraint(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
fileRef, _ := id.UploadFile("some-stuff.txt", "hello", time.Unix(123, 0))
qt.t.Logf("fileRef = %q", fileRef)
p1 := id.NewPlannedPermanode("1")
id.SetAttribute(p1, "camliContent", fileRef.String())
fileRef2, _ := id.UploadFile("other-file", "hellooooo", time.Unix(456, 0))
qt.t.Logf("fileRef2 = %q", fileRef2)
p2 := id.NewPlannedPermanode("2")
id.SetAttribute(p2, "camliContent", fileRef2.String())
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "camliContent",
ValueInSet: &Constraint{
File: &FileConstraint{
FileName: &StringConstraint{
Contains: "-stuff",
},
FileSize: &IntConstraint{
Max: 5,
},
},
},
},
},
}
qt.wantRes(sq, p1)
})
}
func TestQueryFileConstraint_WholeRef(t *testing.T) {
testQueryTypes(t, memIndexTypes, func(qt *queryTest) {
id := qt.id
fileRef, _ := id.UploadFile("some-stuff.txt", "hello", time.Unix(123, 0))
qt.t.Logf("fileRef = %q", fileRef)
p1 := id.NewPlannedPermanode("1")
id.SetAttribute(p1, "camliContent", fileRef.String())
fileRef2, _ := id.UploadFile("other-file", "hellooooo", time.Unix(456, 0))
qt.t.Logf("fileRef2 = %q", fileRef2)
p2 := id.NewPlannedPermanode("2")
id.SetAttribute(p2, "camliContent", fileRef2.String())
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "camliContent",
ValueInSet: &Constraint{
File: &FileConstraint{
WholeRef: blob.RefFromString("hello"),
},
},
},
},
}
qt.wantRes(sq, p1)
})
}
func TestQueryPermanodeModtime(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
// indextest advances time one second per operation:
p1 := id.NewPlannedPermanode("1")
p2 := id.NewPlannedPermanode("2")
p3 := id.NewPlannedPermanode("3")
id.SetAttribute(p1, "someAttr", "value1") // 2011-11-28 01:32:37.000123456 +0000 UTC 1322443957
id.SetAttribute(p2, "someAttr", "value2") // 2011-11-28 01:32:38.000123456 +0000 UTC 1322443958
id.SetAttribute(p3, "someAttr", "value3") // 2011-11-28 01:32:39.000123456 +0000 UTC 1322443959
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
ModTime: &TimeConstraint{
After: types.Time3339(time.Unix(1322443957, 456789)),
Before: types.Time3339(time.Unix(1322443959, 0)),
},
},
},
}
qt.wantRes(sq, p2)
})
}
// This really belongs in pkg/index for the index-vs-corpus tests, but
// it's easier here for now.
// TODO: make all the indextest/tests.go
// also test the three memory build modes that testQuery does.
func TestDecodeFileInfo(t *testing.T) {
ctx := context.Background()
testQuery(t, func(qt *queryTest) {
id := qt.id
fileRef, wholeRef := id.UploadFile("file.gif", "GIF87afoo", time.Unix(456, 0))
res, err := qt.Handler().Describe(ctx, &DescribeRequest{
BlobRef: fileRef,
})
if err != nil {
qt.t.Error(err)
return
}
db := res.Meta[fileRef.String()]
if db == nil {
qt.t.Error("DescribedBlob missing")
return
}
if db.File == nil {
qt.t.Error("DescribedBlob.File is nil")
return
}
if db.File.MIMEType != "image/gif" {
qt.t.Errorf("DescribedBlob.File = %+v; mime type is not image/gif", db.File)
return
}
if db.File.WholeRef != wholeRef {
qt.t.Errorf("DescribedBlob.WholeRef: got %v, wanted %v", wholeRef, db.File.WholeRef)
return
}
})
}
func TestQueryFileCandidateSource(t *testing.T) {
testQueryTypes(t, memIndexTypes, func(qt *queryTest) {
id := qt.id
fileRef, _ := id.UploadFile("some-stuff.txt", "hello", time.Unix(123, 0))
qt.t.Logf("fileRef = %q", fileRef)
p1 := id.NewPlannedPermanode("1")
id.SetAttribute(p1, "camliContent", fileRef.String())
sq := &SearchQuery{
Constraint: &Constraint{
File: &FileConstraint{
WholeRef: blob.RefFromString("hello"),
},
},
}
qt.candidateSource = "corpus_file_meta"
qt.wantRes(sq, fileRef)
})
}
func TestQueryRecentPermanodes_UnspecifiedSort(t *testing.T) {
testQueryRecentPermanodes(t, UnspecifiedSort, "corpus_permanode_created")
}
func TestQueryRecentPermanodes_LastModifiedDesc(t *testing.T) {
testQueryRecentPermanodes(t, LastModifiedDesc, "corpus_permanode_lastmod")
}
func TestQueryRecentPermanodes_CreatedDesc(t *testing.T) {
testQueryRecentPermanodes(t, CreatedDesc, "corpus_permanode_created")
}
func testQueryRecentPermanodes(t *testing.T, sortType SortType, source string) {
testQueryTypes(t, memIndexTypes, func(qt *queryTest) {
id := qt.id
p1 := id.NewPlannedPermanode("1")
id.SetAttribute(p1, "foo", "p1")
p2 := id.NewPlannedPermanode("2")
id.SetAttribute(p2, "foo", "p2")
p3 := id.NewPlannedPermanode("3")
id.SetAttribute(p3, "foo", "p3")
var usedSource string
ExportSetCandidateSourceHook(func(s string) {
usedSource = s
})
req := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{},
},
Limit: 2,
Sort: sortType,
Describe: &DescribeRequest{},
}
handler := qt.Handler()
res, err := handler.Query(ctxbg, req)
if err != nil {
qt.t.Fatal(err)
}
if usedSource != source {
t.Errorf("used candidate source strategy %q; want %v", usedSource, source)
}
wantBlobs := []*SearchResultBlob{
{Blob: p3},
{Blob: p2},
}
if !reflect.DeepEqual(res.Blobs, wantBlobs) {
gotj, wantj := prettyJSON(res.Blobs), prettyJSON(wantBlobs)
t.Errorf("Got blobs:\n%s\nWant:\n%s\n", gotj, wantj)
}
if got := len(res.Describe.Meta); got != 2 {
t.Errorf("got %d described blobs; want 2", got)
}
// And test whether continue (for infinite scroll) works:
{
if got, want := res.Continue, "pn:1322443958000123456:sha1-fbb5be10fcb4c88d32cfdddb20a7b8d13e9ba284"; got != want {
t.Fatalf("Continue token = %q; want %q", got, want)
}
req := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{},
},
Limit: 2,
Sort: sortType,
Continue: res.Continue,
}
res, err := handler.Query(ctxbg, req)
if err != nil {
qt.t.Fatal(err)
}
wantBlobs := []*SearchResultBlob{{Blob: p1}}
if !reflect.DeepEqual(res.Blobs, wantBlobs) {
gotj, wantj := prettyJSON(res.Blobs), prettyJSON(wantBlobs)
t.Errorf("After scroll, got blobs:\n%s\nWant:\n%s\n", gotj, wantj)
}
}
})
}
func TestQueryRecentPermanodes_Continue_UnspecifiedSort(t *testing.T) {
testQueryRecentPermanodes_Continue(t, UnspecifiedSort)
}
func TestQueryRecentPermanodes_Continue_LastModifiedDesc(t *testing.T) {
testQueryRecentPermanodes_Continue(t, LastModifiedDesc)
}
func TestQueryRecentPermanodes_Continue_CreatedDesc(t *testing.T) {
testQueryRecentPermanodes_Continue(t, CreatedDesc)
}
// Tests the continue token on recent permanodes, notably when the
// page limit truncates in the middle of a bunch of permanodes with the
// same modtime.
func testQueryRecentPermanodes_Continue(t *testing.T, sortType SortType) {
testQueryTypes(t, memIndexTypes, func(qt *queryTest) {
id := qt.id
var blobs []blob.Ref
for i := 1; i <= 4; i++ {
pn := id.NewPlannedPermanode(fmt.Sprint(i))
blobs = append(blobs, pn)
t.Logf("permanode %d is %v", i, pn)
id.SetAttribute_NoTimeMove(pn, "foo", "bar")
}
sort.Sort(blob.ByRef(blobs))
for i, br := range blobs {
t.Logf("Sorted %d = %v", i, br)
}
handler := qt.Handler()
contToken := ""
tests := [][]blob.Ref{
{blobs[3], blobs[2]},
{blobs[1], blobs[0]},
{},
}
for i, wantBlobs := range tests {
req := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{},
},
Limit: 2,
Sort: sortType,
Continue: contToken,
}
res, err := handler.Query(ctxbg, req)
if err != nil {
qt.t.Fatalf("Error on query %d: %v", i+1, err)
}
t.Logf("Query %d/%d: continue = %q", i+1, len(tests), res.Continue)
for i, sb := range res.Blobs {
t.Logf(" res[%d]: %v", i, sb.Blob)
}
var want []*SearchResultBlob
for _, br := range wantBlobs {
want = append(want, &SearchResultBlob{Blob: br})
}
if !reflect.DeepEqual(res.Blobs, want) {
gotj, wantj := prettyJSON(res.Blobs), prettyJSON(want)
t.Fatalf("Query %d: Got blobs:\n%s\nWant:\n%s\n", i+1, gotj, wantj)
}
contToken = res.Continue
haveToken := contToken != ""
wantHaveToken := (i + 1) < len(tests)
if haveToken != wantHaveToken {
t.Fatalf("Query %d: token = %q; want token = %v", i+1, contToken, wantHaveToken)
}
}
})
}
func TestQueryRecentPermanodes_ContinueEndMidPage_UnspecifiedSort(t *testing.T) {
testQueryRecentPermanodes_ContinueEndMidPage(t, UnspecifiedSort)
}
func TestQueryRecentPermanodes_ContinueEndMidPage_LastModifiedDesc(t *testing.T) {
testQueryRecentPermanodes_ContinueEndMidPage(t, LastModifiedDesc)
}
func TestQueryRecentPermanodes_ContinueEndMidPage_CreatedDesc(t *testing.T) {
testQueryRecentPermanodes_ContinueEndMidPage(t, CreatedDesc)
}
// Tests continue token hitting the end mid-page.
func testQueryRecentPermanodes_ContinueEndMidPage(t *testing.T, sortType SortType) {
testQueryTypes(t, memIndexTypes, func(qt *queryTest) {
id := qt.id
var blobs []blob.Ref
for i := 1; i <= 3; i++ {
pn := id.NewPlannedPermanode(fmt.Sprint(i))
blobs = append(blobs, pn)
t.Logf("permanode %d is %v", i, pn)
id.SetAttribute_NoTimeMove(pn, "foo", "bar")
}
sort.Sort(blob.ByRef(blobs))
for i, br := range blobs {
t.Logf("Sorted %d = %v", i, br)
}
handler := qt.Handler()
contToken := ""
tests := [][]blob.Ref{
{blobs[2], blobs[1]},
{blobs[0]},
}
for i, wantBlobs := range tests {
req := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{},
},
Limit: 2,
Sort: sortType,
Continue: contToken,
}
res, err := handler.Query(ctxbg, req)
if err != nil {
qt.t.Fatalf("Error on query %d: %v", i+1, err)
}
t.Logf("Query %d/%d: continue = %q", i+1, len(tests), res.Continue)
for i, sb := range res.Blobs {
t.Logf(" res[%d]: %v", i, sb.Blob)
}
var want []*SearchResultBlob
for _, br := range wantBlobs {
want = append(want, &SearchResultBlob{Blob: br})
}
if !reflect.DeepEqual(res.Blobs, want) {
gotj, wantj := prettyJSON(res.Blobs), prettyJSON(want)
t.Fatalf("Query %d: Got blobs:\n%s\nWant:\n%s\n", i+1, gotj, wantj)
}
contToken = res.Continue
haveToken := contToken != ""
wantHaveToken := (i + 1) < len(tests)
if haveToken != wantHaveToken {
t.Fatalf("Query %d: token = %q; want token = %v", i+1, contToken, wantHaveToken)
}
}
})
}
// Tests PermanodeConstraint.ValueAll
func TestQueryPermanodeValueAll(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
p1 := id.NewPlannedPermanode("1")
p2 := id.NewPlannedPermanode("2")
id.SetAttribute(p1, "attr", "foo")
id.SetAttribute(p1, "attr", "barrrrr")
id.SetAttribute(p2, "attr", "foo")
id.SetAttribute(p2, "attr", "bar")
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "attr",
ValueAll: true,
ValueMatches: &StringConstraint{
ByteLength: &IntConstraint{
Min: 3,
Max: 3,
},
},
},
},
}
qt.wantRes(sq, p2)
})
}
// Tests PermanodeConstraint.ValueMatches.CaseInsensitive.
func TestQueryPermanodeValueMatchesCaseInsensitive(t *testing.T) {
testQuery(t, func(qt *queryTest) {
id := qt.id
p1 := id.NewPlannedPermanode("1")
p2 := id.NewPlannedPermanode("2")
id.SetAttribute(p1, "x", "Foo")
id.SetAttribute(p2, "x", "start")
sq := &SearchQuery{
Constraint: &Constraint{
Logical: &LogicalConstraint{
Op: "or",
A: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "x",
ValueMatches: &StringConstraint{
Equals: "foo",
CaseInsensitive: true,
},
},
},
B: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "x",
ValueMatches: &StringConstraint{
Contains: "TAR",
CaseInsensitive: true,
},
},
},
},
},
}
qt.wantRes(sq, p1, p2)
})
}
func TestQueryChildren(t *testing.T) {
testQueryTypes(t, memIndexTypes, func(qt *queryTest) {
id := qt.id
pdir := id.NewPlannedPermanode("some_dir")
p1 := id.NewPlannedPermanode("1")
p2 := id.NewPlannedPermanode("2")
p3 := id.NewPlannedPermanode("3")
id.AddAttribute(pdir, "camliMember", p1.String())
id.AddAttribute(pdir, "camliPath:foo", p2.String())
id.AddAttribute(pdir, "other", p3.String())
// Make p1, p2, and p3 actually exist. (permanodes without attributes are dead)
id.AddAttribute(p1, "x", "x")
id.AddAttribute(p2, "x", "x")
id.AddAttribute(p3, "x", "x")
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Relation: &RelationConstraint{
Relation: "parent",
Any: &Constraint{
BlobRefPrefix: pdir.String(),
},
},
},
},
}
qt.wantRes(sq, p1, p2)
})
}
func TestQueryParent(t *testing.T) {
testQueryTypes(t, memIndexTypes, func(qt *queryTest) {
id := qt.id
pdir1 := id.NewPlannedPermanode("some_dir_1")
pdir2 := id.NewPlannedPermanode("some_dir_2")
p1 := id.NewPlannedPermanode("1")
p2 := id.NewPlannedPermanode("2")
p3 := id.NewPlannedPermanode("3")
id.AddAttribute(pdir1, "camliMember", p1.String())
id.AddAttribute(pdir1, "camliPath:foo", p2.String())
id.AddAttribute(pdir1, "other", p3.String())
id.AddAttribute(pdir2, "camliPath:bar", p1.String())
// Make p1, p2, and p3 actually exist. (permanodes without attributes are dead)
id.AddAttribute(p1, "x", "x")
id.AddAttribute(p2, "x", "x")
id.AddAttribute(p3, "x", "x")
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Relation: &RelationConstraint{
Relation: "child",
Any: &Constraint{
BlobRefPrefix: p1.String(),
},
},
},
},
}
qt.wantRes(sq, pdir1, pdir2)
sq = &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Relation: &RelationConstraint{
Relation: "child",
Any: &Constraint{
BlobRefPrefix: p2.String(),
},
},
},
},
}
qt.wantRes(sq, pdir1)
sq = &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Relation: &RelationConstraint{
Relation: "child",
Any: &Constraint{
BlobRefPrefix: p3.String(),
},
},
},
},
}
qt.wantRes(sq)
})
}
// tests the algorithm for the Around parameter, when the source of blobs is
// unsorted, i.e. when the blobs get sorted right after the constraint has been
// matched, and right before Around is applied.
func testAroundUnsortedSource(limit, pos int, t *testing.T) {
testQueryTypes(t, []indexType{indexClassic}, func(qt *queryTest) {
id := qt.id
var sorted []string
unsorted := make(map[string]blob.Ref)
addToSorted := func(i int) {
p := id.NewPlannedPermanode(fmt.Sprintf("%d", i))
unsorted[p.String()] = p
sorted = append(sorted, p.String())
}
for i := 0; i < 10; i++ {
addToSorted(i)
}
sort.Strings(sorted)
// Predict the results
var want []blob.Ref
var around blob.Ref
lowLimit := pos - limit/2
if lowLimit < 0 {
lowLimit = 0
}
highLimit := lowLimit + limit
if highLimit > len(sorted) {
highLimit = len(sorted)
}
// Make the permanodes actually exist.
for k, v := range sorted {
pn := unsorted[v]
id.AddAttribute(pn, "x", "x")
if k == pos {
around = pn
}
if k >= lowLimit && k < highLimit {
want = append(want, pn)
}
}
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{},
},
Limit: limit,
Around: around,
Sort: BlobRefAsc,
}
qt.wantRes(sq, want...)
})
}
func TestQueryAroundCenter(t *testing.T) {
testAroundUnsortedSource(4, 4, t)
}
func TestQueryAroundNear(t *testing.T) {
testAroundUnsortedSource(5, 9, t)
}
func TestQueryAroundFar(t *testing.T) {
testAroundUnsortedSource(3, 4, t)
}
// 13 permanodes are created. 1 of them the parent, 11 are children
// (== results), 1 is unrelated to the parent.
// limit is the limit on the number of results.
// pos is the position of the around permanode.
// note: pos is in the permanode creation order, but keep in mind
// they're enumerated in the opposite order.
func testAroundChildren(limit, pos int, t *testing.T) {
testQueryTypes(t, memIndexTypes, func(qt *queryTest) {
id := qt.id
pdir := id.NewPlannedPermanode("some_dir")
p0 := id.NewPlannedPermanode("0")
p1 := id.NewPlannedPermanode("1")
p2 := id.NewPlannedPermanode("2")
p3 := id.NewPlannedPermanode("3")
p4 := id.NewPlannedPermanode("4")
p5 := id.NewPlannedPermanode("5")
p6 := id.NewPlannedPermanode("6")
p7 := id.NewPlannedPermanode("7")
p8 := id.NewPlannedPermanode("8")
p9 := id.NewPlannedPermanode("9")
p10 := id.NewPlannedPermanode("10")
p11 := id.NewPlannedPermanode("11")
id.AddAttribute(pdir, "camliMember", p0.String())
id.AddAttribute(pdir, "camliMember", p1.String())
id.AddAttribute(pdir, "camliPath:foo", p2.String())
const noMatchIndex = 3
id.AddAttribute(pdir, "other", p3.String())
id.AddAttribute(pdir, "camliPath:bar", p4.String())
id.AddAttribute(pdir, "camliMember", p5.String())
id.AddAttribute(pdir, "camliMember", p6.String())
id.AddAttribute(pdir, "camliMember", p7.String())
id.AddAttribute(pdir, "camliMember", p8.String())
id.AddAttribute(pdir, "camliMember", p9.String())
id.AddAttribute(pdir, "camliMember", p10.String())
id.AddAttribute(pdir, "camliMember", p11.String())
// Predict the results
var around blob.Ref
lowLimit := pos - limit/2
if lowLimit <= noMatchIndex {
// Because 3 is not included in the results
lowLimit--
}
if lowLimit < 0 {
lowLimit = 0
}
highLimit := lowLimit + limit
if highLimit >= noMatchIndex {
// Because noMatchIndex is not included in the results
highLimit++
}
var want []blob.Ref
// Make the permanodes actually exist. (permanodes without attributes are dead)
for k, v := range []blob.Ref{p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11} {
id.AddAttribute(v, "x", "x")
if k == pos {
around = v
}
if k != noMatchIndex && k >= lowLimit && k < highLimit {
want = append(want, v)
}
}
// invert the order because the results are appended in reverse creation order
// because that's how we enumerate.
revWant := make([]blob.Ref, len(want))
for k, v := range want {
revWant[len(want)-1-k] = v
}
sq := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Relation: &RelationConstraint{
Relation: "parent",
Any: &Constraint{
BlobRefPrefix: pdir.String(),
},
},
},
},
Limit: limit,
Around: around,
}
qt.wantRes(sq, revWant...)
})
}
// TODO(mpl): more tests. at least the 0 results case.
// Around will be found in the first buffered window of results,
// because it's a position that fits within the limit.
// So it doesn't exercice the part of the algorithm that discards
// the would-be results that are not within the "around zone".
func TestQueryChildrenAroundNear(t *testing.T) {
testAroundChildren(5, 9, t)
}
// pos is near the end of the results enumeration and the limit is small
// so this test should go through the part of the algorithm that discards
// results not within the "around zone".
func TestQueryChildrenAroundFar(t *testing.T) {
testAroundChildren(3, 4, t)
}
// permanodes tagged "foo" or those in sets where the parent
// permanode set itself is tagged "foo".
func TestQueryPermanodeTaggedViaParent(t *testing.T) {
t.Skip("TODO: finish implementing")
testQuery(t, func(qt *queryTest) {
id := qt.id
ptagged := id.NewPlannedPermanode("tagged_photo")
pindirect := id.NewPlannedPermanode("via_parent")
pset := id.NewPlannedPermanode("set")
pboth := id.NewPlannedPermanode("both") // funny directly and via its parent
pnotfunny := id.NewPlannedPermanode("not_funny")
id.SetAttribute(ptagged, "tag", "funny")
id.SetAttribute(pset, "tag", "funny")
id.SetAttribute(pboth, "tag", "funny")
id.AddAttribute(pset, "camliMember", pindirect.String())
id.AddAttribute(pset, "camliMember", pboth.String())
id.SetAttribute(pnotfunny, "tag", "boring")
sq := &SearchQuery{
Constraint: &Constraint{
Logical: &LogicalConstraint{
Op: "or",
// Those tagged funny directly:
A: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "tag",
Value: "funny",
},
},
// Those tagged funny indirectly:
B: &Constraint{
Permanode: &PermanodeConstraint{
Relation: &RelationConstraint{
Relation: "ancestor",
Any: &Constraint{
Permanode: &PermanodeConstraint{
Attr: "tag",
Value: "funny",
},
},
},
},
},
},
},
}
qt.wantRes(sq, ptagged, pset, pboth, pindirect)
})
}
func TestLimitDoesntDeadlock_UnspecifiedSort(t *testing.T) {
testLimitDoesntDeadlock(t, UnspecifiedSort)
}
func TestLimitDoesntDeadlock_LastModifiedDesc(t *testing.T) {
testLimitDoesntDeadlock(t, LastModifiedDesc)
}
func TestLimitDoesntDeadlock_CreatedDesc(t *testing.T) {
testLimitDoesntDeadlock(t, CreatedDesc)
}
func testLimitDoesntDeadlock(t *testing.T, sortType SortType) {
testQueryTypes(t, memIndexTypes, func(qt *queryTest) {
id := qt.id
const limit = 2
for i := 0; i < ExportBufferedConst()+limit+1; i++ {
pn := id.NewPlannedPermanode(fmt.Sprint(i))
id.SetAttribute(pn, "foo", "bar")
}
req := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{},
},
Limit: limit,
Sort: sortType,
Describe: &DescribeRequest{},
}
h := qt.Handler()
gotRes := make(chan bool, 1)
go func() {
_, err := h.Query(ctxbg, req)
if err != nil {
qt.t.Error(err)
}
gotRes <- true
}()
select {
case <-gotRes:
case <-time.After(5 * time.Second):
t.Error("timeout; deadlock?")
}
})
}
func prettyJSON(v interface{}) string {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
panic(err)
}
return string(b)
}
func TestPlannedQuery(t *testing.T) {
tests := []struct {
in, want *SearchQuery
}{
{
in: &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{},
},
},
want: &SearchQuery{
Sort: CreatedDesc,
Constraint: &Constraint{
Permanode: &PermanodeConstraint{},
},
Limit: 200,
},
},
}
for i, tt := range tests {
got := tt.in.ExportPlannedQuery()
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("%d. for input:\n%s\ngot:\n%s\nwant:\n%s\n", i,
prettyJSON(tt.in), prettyJSON(got), prettyJSON(tt.want))
}
}
}
func TestDescribeMarshal(t *testing.T) {
// Empty Describe
q := &SearchQuery{
Describe: &DescribeRequest{},
}
enc, err := json.Marshal(q)
if err != nil {
t.Fatal(err)
}
if got, want := string(enc), `{"around":null,"describe":{"blobref":null,"at":null}}`; got != want {
t.Errorf("JSON: %s; want %s", got, want)
}
back := &SearchQuery{}
err = json.Unmarshal(enc, back)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(q, back) {
t.Errorf("Didn't round-trip. Got %#v; want %#v", back, q)
}
// DescribeRequest with multiple blobref
q = &SearchQuery{
Describe: &DescribeRequest{
BlobRefs: []blob.Ref{blob.MustParse("sha-1234"), blob.MustParse("sha-abcd")},
},
}
enc, err = json.Marshal(q)
if err != nil {
t.Fatal(err)
}
if got, want := string(enc), `{"around":null,"describe":{"blobrefs":["sha-1234","sha-abcd"],"blobref":null,"at":null}}`; got != want {
t.Errorf("JSON: %s; want %s", got, want)
}
back = &SearchQuery{}
err = json.Unmarshal(enc, back)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(q, back) {
t.Errorf("Didn't round-trip. Got %#v; want %#v", back, q)
}
// and the zero value
q = &SearchQuery{}
enc, err = json.Marshal(q)
if err != nil {
t.Fatal(err)
}
if string(enc) != `{"around":null}` {
t.Errorf(`Zero value: %q; want null`, enc)
}
}
func TestSortMarshal_UnspecifiedSort(t *testing.T) {
testSortMarshal(t, UnspecifiedSort)
}
func TestSortMarshal_LastModifiedDesc(t *testing.T) {
testSortMarshal(t, LastModifiedDesc)
}
func TestSortMarshal_CreatedDesc(t *testing.T) {
testSortMarshal(t, CreatedDesc)
}
var sortMarshalWant = map[SortType]string{
UnspecifiedSort: `{"around":null}`,
LastModifiedDesc: `{"sort":` + string(SortName[LastModifiedDesc]) + `,"around":null}`,
CreatedDesc: `{"sort":` + string(SortName[CreatedDesc]) + `,"around":null}`,
}
func testSortMarshal(t *testing.T, sortType SortType) {
q := &SearchQuery{
Sort: sortType,
}
enc, err := json.Marshal(q)
if err != nil {
t.Fatal(err)
}
if got, want := string(enc), sortMarshalWant[sortType]; got != want {
t.Errorf("JSON: %s; want %s", got, want)
}
back := &SearchQuery{}
err = json.Unmarshal(enc, back)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(q, back) {
t.Errorf("Didn't round-trip. Got %#v; want %#v", back, q)
}
// and the zero value
q = &SearchQuery{}
enc, err = json.Marshal(q)
if err != nil {
t.Fatal(err)
}
if string(enc) != `{"around":null}` {
t.Errorf("Zero value: %s; want {}", enc)
}
}
func BenchmarkQueryRecentPermanodes(b *testing.B) {
b.ReportAllocs()
testQueryTypes(b, corpusTypeOnly, func(qt *queryTest) {
id := qt.id
p1 := id.NewPlannedPermanode("1")
id.SetAttribute(p1, "foo", "p1")
p2 := id.NewPlannedPermanode("2")
id.SetAttribute(p2, "foo", "p2")
p3 := id.NewPlannedPermanode("3")
id.SetAttribute(p3, "foo", "p3")
req := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{},
},
Limit: 2,
Sort: UnspecifiedSort,
Describe: &DescribeRequest{},
}
h := qt.Handler()
b.ResetTimer()
for i := 0; i < b.N; i++ {
*req.Describe = DescribeRequest{}
_, err := h.Query(ctxbg, req)
if err != nil {
qt.t.Fatal(err)
}
}
})
}
func BenchmarkQueryPermanodes(b *testing.B) {
benchmarkQueryPermanodes(b, false)
}
func BenchmarkQueryDescribePermanodes(b *testing.B) {
benchmarkQueryPermanodes(b, true)
}
func benchmarkQueryPermanodes(b *testing.B, describe bool) {
b.ReportAllocs()
testQueryTypes(b, corpusTypeOnly, func(qt *queryTest) {
id := qt.id
for i := 0; i < 1000; i++ {
pn := id.NewPlannedPermanode(fmt.Sprint(i))
id.SetAttribute(pn, "foo", fmt.Sprint(i))
}
req := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{},
},
}
if describe {
req.Describe = &DescribeRequest{}
}
h := qt.Handler()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if describe {
*req.Describe = DescribeRequest{}
}
_, err := h.Query(ctxbg, req)
if err != nil {
qt.t.Fatal(err)
}
}
})
}
func BenchmarkQueryPermanodeLocation(b *testing.B) {
b.ReportAllocs()
testQueryTypes(b, corpusTypeOnly, func(qt *queryTest) {
id := qt.id
// Upload a basic image
camliRootPath, err := osutil.GoPackagePath("perkeep.org")
if err != nil {
panic("Package perkeep.org not found in $GOPATH or $GOPATH not defined")
}
uploadFile := func(file string, modTime time.Time) blob.Ref {
fileName := filepath.Join(camliRootPath, "pkg", "search", "testdata", file)
contents, err := ioutil.ReadFile(fileName)
if err != nil {
panic(err)
}
br, _ := id.UploadFile(file, string(contents), modTime)
return br
}
fileRef := uploadFile("dude-gps.jpg", time.Time{})
var n int
newPn := func() blob.Ref {
n++
return id.NewPlannedPermanode(fmt.Sprint(n))
}
pn := id.NewPlannedPermanode("photo")
id.SetAttribute(pn, "camliContent", fileRef.String())
for i := 0; i < 5; i++ {
pn := newPn()
id.SetAttribute(pn, "camliNodeType", "foursquare.com:venue")
id.SetAttribute(pn, "latitude", fmt.Sprint(50-i))
id.SetAttribute(pn, "longitude", fmt.Sprint(i))
for j := 0; j < 5; j++ {
qn := newPn()
id.SetAttribute(qn, "camliNodeType", "foursquare.com:checkin")
id.SetAttribute(qn, "foursquareVenuePermanode", pn.String())
}
}
for i := 0; i < 10; i++ {
pn := newPn()
id.SetAttribute(pn, "foo", fmt.Sprint(i))
}
req := &SearchQuery{
Constraint: &Constraint{
Permanode: &PermanodeConstraint{
Location: &LocationConstraint{Any: true},
},
},
}
h := qt.Handler()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := h.Query(ctxbg, req)
if err != nil {
qt.t.Fatal(err)
}
}
})
}
// BenchmarkLocationPredicate aims at measuring the impact of
// https://camlistore-review.googlesource.com/8049
// ( + https://camlistore-review.googlesource.com/8649)
// on location queries.
// It populates the corpus with enough fake foursquare checkins/venues and
// twitter locations to look realistic.
func BenchmarkLocationPredicate(b *testing.B) {
b.ReportAllocs()
testQueryTypes(b, corpusTypeOnly, func(qt *queryTest) {
id := qt.id
var n int
newPn := func() blob.Ref {
n++
return id.NewPlannedPermanode(fmt.Sprint(n))
}
// create (~700) venues all over the world, and mark 25% of them as places we've been to
venueIdx := 0
for long := -180.0; long < 180.0; long += 10.0 {
for lat := -90.0; lat < 90.0; lat += 10.0 {
pn := newPn()
id.SetAttribute(pn, "camliNodeType", "foursquare.com:venue")
id.SetAttribute(pn, "latitude", fmt.Sprintf("%f", lat))
id.SetAttribute(pn, "longitude", fmt.Sprintf("%f", long))
if venueIdx%4 == 0 {
qn := newPn()
id.SetAttribute(qn, "camliNodeType", "foursquare.com:checkin")
id.SetAttribute(qn, "foursquareVenuePermanode", pn.String())
}
venueIdx++
}
}
// create 3K tweets, all with locations
lat := 45.18
long := 5.72
for i := 0; i < 3000; i++ {
pn := newPn()
id.SetAttribute(pn, "camliNodeType", "twitter.com:tweet")
id.SetAttribute(pn, "latitude", fmt.Sprintf("%f", lat))
id.SetAttribute(pn, "longitude", fmt.Sprintf("%f", long))
lat += 0.01
long += 0.01
}
// create 5K additional permanodes, but no location. Just as "noise".
for i := 0; i < 5000; i++ {
newPn()
}
// Create ~2600 photos all over the world.
for long := -180.0; long < 180.0; long += 5.0 {
for lat := -90.0; lat < 90.0; lat += 5.0 {
br, _ := id.UploadFile("photo.jpg", exifFileContentLatLong(lat, long), time.Time{})
pn := newPn()
id.SetAttribute(pn, "camliContent", br.String())
}
}
h := qt.Handler()
b.ResetTimer()
locations := []string{
"canada", "scotland", "france", "sweden", "germany", "poland", "russia", "algeria", "congo", "china", "india", "australia", "mexico", "brazil", "argentina",
}
for i := 0; i < b.N; i++ {
for _, loc := range locations {
req := &SearchQuery{
Expression: "loc:" + loc,
Limit: -1,
}
resp, err := h.Query(ctxbg, req)
if err != nil {
qt.t.Fatal(err)
}
b.Logf("found %d permanodes in %v", len(resp.Blobs), loc)
}
}
})
}
var altLocCache = make(map[string][]geocode.Rect)
func init() {
cacheGeo := func(address string, n, e, s, w float64) {
altLocCache[address] = []geocode.Rect{{
NorthEast: geocode.LatLong{Lat: n, Long: e},
SouthWest: geocode.LatLong{Lat: s, Long: w},
}}
}
cacheGeo("canada", 83.0956562, -52.6206965, 41.6765559, -141.00187)
cacheGeo("scotland", 60.8607515, -0.7246751, 54.6332381, -8.6498706)
cacheGeo("france", 51.0891285, 9.560067700000001, 41.3423275, -5.142307499999999)
cacheGeo("sweden", 69.0599709, 24.1665922, 55.3367024, 10.9631865)
cacheGeo("germany", 55.0581235, 15.0418962, 47.2701114, 5.8663425)
cacheGeo("poland", 54.835784, 24.1458933, 49.0020251, 14.1228641)
cacheGeo("russia", 81.858122, -169.0456324, 41.1853529, 19.6404268)
cacheGeo("algeria", 37.0898204, 11.999999, 18.968147, -8.667611299999999)
cacheGeo("congo", 3.707791, 18.6436109, -5.0289719, 11.1530037)
cacheGeo("china", 53.5587015, 134.7728098, 18.1576156, 73.4994136)
cacheGeo("india", 35.5087008, 97.395561, 6.7535159, 68.1623859)
cacheGeo("australia", -9.2198214, 159.2557541, -54.7772185, 112.9215625)
cacheGeo("mexico", 32.7187629, -86.7105711, 14.5345486, -118.3649292)
cacheGeo("brazil", 5.2717863, -29.3448224, -33.7506241, -73.98281709999999)
cacheGeo("argentina", -21.7810459, -53.6374811, -55.05727899999999, -73.56036019999999)
geocode.AltLookupFn = func(ctx context.Context, addr string) ([]geocode.Rect, error) {
r, ok := altLocCache[addr]
if ok {
return r, nil
}
return nil, nil
}
}
var exifFileContent struct {
once sync.Once
jpeg []byte
}
// exifFileContentLatLong returns the contents of a
// jpeg/exif file with the GPS coordinates lat and long.
func exifFileContentLatLong(lat, long float64) string {
exifFileContent.once.Do(func() {
var buf bytes.Buffer
jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 128, 128)), nil)
exifFileContent.jpeg = buf.Bytes()
})
x := rawExifLatLong(lat, long)
j := exifFileContent.jpeg
app1sec := []byte{0xff, 0xe1, 0, 0}
binary.BigEndian.PutUint16(app1sec[2:], uint16(len(x)+2))
p := make([]byte, 0, len(j)+len(app1sec)+len(x))
p = append(p, j[:2]...) // ff d8
p = append(p, app1sec...) // exif section header
p = append(p, x...) // raw exif
p = append(p, j[2:]...) // jpeg image
return string(p)
}
// rawExifLatLong creates raw exif for lat/long
// for storage in a jpeg file.
func rawExifLatLong(lat, long float64) []byte {
x := exifBuf{
bo: binary.BigEndian,
p: []byte("MM"),
}
x.putUint16(42) // magic
ifd0ofs := x.reservePtr() // room for ifd0 offset
x.storePtr(ifd0ofs)
const (
gpsSubIfdTag = 0x8825
gpsLatitudeRef = 1
gpsLatitude = 2
gpsLongitudeRef = 3
gpsLongitude = 4
typeAscii = 2
typeLong = 4
typeRational = 5
)
// IFD0
x.storePtr(ifd0ofs)
x.putUint16(1) // 1 tag
x.putTag(gpsSubIfdTag, typeLong, 1)
gpsofs := x.reservePtr()
// IFD1
x.putUint32(0) // no IFD1
// GPS sub-IFD
x.storePtr(gpsofs)
x.putUint16(4) // 4 tags
x.putTag(gpsLatitudeRef, typeAscii, 2)
if lat >= 0 {
x.next(4)[0] = 'N'
} else {
x.next(4)[0] = 'S'
}
x.putTag(gpsLatitude, typeRational, 3)
latptr := x.reservePtr()
x.putTag(gpsLongitudeRef, typeAscii, 2)
if long >= 0 {
x.next(4)[0] = 'E'
} else {
x.next(4)[0] = 'W'
}
x.putTag(gpsLongitude, typeRational, 3)
longptr := x.reservePtr()
// write data referenced in GPS sub-IFD
x.storePtr(latptr)
x.putDegMinSecRat(lat)
x.storePtr(longptr)
x.putDegMinSecRat(long)
return append([]byte("Exif\x00\x00"), x.p...)
}
type exifBuf struct {
bo binary.ByteOrder
p []byte
}
func (x *exifBuf) next(n int) []byte {
l := len(x.p)
x.p = append(x.p, make([]byte, n)...)
return x.p[l:]
}
func (x *exifBuf) putTag(tag, typ uint16, len uint32) {
x.putUint16(tag)
x.putUint16(typ)
x.putUint32(len)
}
func (x *exifBuf) putUint16(n uint16) { x.bo.PutUint16(x.next(2), n) }
func (x *exifBuf) putUint32(n uint32) { x.bo.PutUint32(x.next(4), n) }
func (x *exifBuf) putDegMinSecRat(v float64) {
if v < 0 {
v = -v
}
deg := uint32(v)
v = 60 * (v - float64(deg))
min := uint32(v)
v = 60 * (v - float64(min))
μsec := uint32(v * 1e6)
x.putUint32(deg)
x.putUint32(1)
x.putUint32(min)
x.putUint32(1)
x.putUint32(μsec)
x.putUint32(1e6)
}
// reservePtr reserves room for a ptr in x.
func (x *exifBuf) reservePtr() int {
l := len(x.p)
x.next(4)
return l
}
// storePtr stores the current write offset at p
// that have been reserved with reservePtr.
func (x *exifBuf) storePtr(p int) {
x.bo.PutUint32(x.p[p:], uint32(len(x.p)))
}
// function to generate data for TestBestByLocation
func generateLocationPoints(north, south, west, east float64, limit int) string {
points := make([]camtypes.Location, limit)
height := north - south
width := east - west
if west >= east {
// area is spanning over the antimeridian
width += 360
}
for i := 0; i < limit; i++ {
lat := rand.Float64()*height + south
long := camtypes.Longitude(rand.Float64()*width + west).WrapTo180()
points[i] = camtypes.Location{
Latitude: lat,
Longitude: long,
}
}
data, err := json.Marshal(points)
if err != nil {
log.Fatal(err)
}
return string(data)
}
type locationPoints struct {
Name string
Comment string
Points []camtypes.Location
}
func TestBestByLocation(t *testing.T) {
if testing.Short() {
t.Skip()
}
data := make(map[string]locationPoints)
f, err := os.Open(filepath.Join("testdata", "locationPoints.json"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
dec := json.NewDecoder(f)
if err := dec.Decode(&data); err != nil {
t.Fatal(err)
}
for _, v := range data {
testBestByLocation(t, v, false)
}
}
// call with generate=true to regenerate the png files int testdata/ from testdata/locationPoints.json
func testBestByLocation(t *testing.T, data locationPoints, generate bool) {
var res SearchResult
var blobs []*SearchResultBlob
meta := make(map[string]*DescribedBlob)
var area camtypes.LocationBounds
locm := make(map[blob.Ref]camtypes.Location)
for _, v := range data.Points {
br := blob.RefFromString(fmt.Sprintf("%v,%v", v.Latitude, v.Longitude))
blobs = append(blobs, &SearchResultBlob{
Blob: br,
})
loc := camtypes.Location{
Latitude: v.Latitude,
Longitude: v.Longitude,
}
meta[br.String()] = &DescribedBlob{
Location: &loc,
}
locm[br] = loc
area = area.Expand(loc)
}
res.Blobs = blobs
res.Describe = &DescribeResponse{
Meta: meta,
}
res.LocationArea = &area
var widthRatio, heightRatio float64
initImage := func() *image.RGBA {
maxRelLat := area.North - area.South
maxRelLong := area.East - area.West
if area.West >= area.East {
// area is spanning over the antimeridian
maxRelLong += 360
}
// draw it all on a 1000 px wide image
height := int(1000 * maxRelLat / maxRelLong)
img := image.NewRGBA(image.Rect(0, 0, 1000, height))
for i := 0; i < 1000; i++ {
for j := 0; j < 1000; j++ {
img.Set(i, j, image.White)
}
}
widthRatio = 1000. / maxRelLong
heightRatio = float64(height) / maxRelLat
return img
}
img := initImage()
for _, v := range data.Points {
// draw a little cross of 3x3, because 1px dot is not visible enough.
relLong := v.Longitude - area.West
if v.Longitude < area.West {
relLong += 360
}
crossX := int(relLong * widthRatio)
crossY := int((area.North - v.Latitude) * heightRatio)
for i := -1; i < 2; i++ {
img.Set(crossX+i, crossY, color.RGBA{127, 0, 0, 127})
}
for j := -1; j < 2; j++ {
img.Set(crossX, crossY+j, color.RGBA{127, 0, 0, 127})
}
}
cmpImage := func(img *image.RGBA, wantImgFile string) {
f, err := os.Open(wantImgFile)
if err != nil {
t.Fatal(err)
}
defer f.Close()
wantImg, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
for j := 0; j < wantImg.Bounds().Max.Y; j++ {
for i := 0; i < wantImg.Bounds().Max.X; i++ {
r1, g1, b1, a1 := wantImg.At(i, j).RGBA()
r2, g2, b2, a2 := img.At(i, j).RGBA()
if r1 != r2 || g1 != g2 || b1 != b2 || a1 != a2 {
t.Fatalf("%v different from %v", wantImg.At(i, j), img.At(i, j))
}
}
}
}
genPng := func(img *image.RGBA, name string) {
f, err := os.Create(name)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
}
if generate {
genPng(img, filepath.Join("testdata", fmt.Sprintf("%v-beforeMapSort.png", data.Name)))
} else {
cmpImage(img, filepath.Join("testdata", fmt.Sprintf("%v-beforeMapSort.png", data.Name)))
}
ExportBestByLocation(&res, locm, 100)
// check that all longitudes are in the [-180,180] range
for _, v := range res.Blobs {
longitude := meta[v.Blob.String()].Location.Longitude
if longitude < -180. || longitude > 180. {
t.Errorf("out of range location: %v", longitude)
}
}
img = initImage()
for _, v := range res.Blobs {
loc := meta[v.Blob.String()].Location
longitude := loc.Longitude
latitude := loc.Latitude
// draw a little cross of 3x3, because 1px dot is not visible enough.
relLong := longitude - area.West
if longitude < area.West {
relLong += 360
}
crossX := int(relLong * widthRatio)
crossY := int((area.North - latitude) * heightRatio)
for i := -1; i < 2; i++ {
img.Set(crossX+i, crossY, color.RGBA{127, 0, 0, 127})
}
for j := -1; j < 2; j++ {
img.Set(crossX, crossY+j, color.RGBA{127, 0, 0, 127})
}
}
if generate {
genPng(img, filepath.Join("testdata", fmt.Sprintf("%v-afterMapSort.png", data.Name)))
} else {
cmpImage(img, filepath.Join("testdata", fmt.Sprintf("%v-afterMapSort.png", data.Name)))
}
}