Page research without exact counts and bound candidate scans [skip ci]

This commit is contained in:
2026-11-07 17:27:35 +08:00
parent 142c7b2d86
commit 026ee22c1e
8 changed files with 563 additions and 86 deletions
+180 -17
View File
@@ -50,31 +50,28 @@ func TestPostgresPageSearchAndFilters(t *testing.T) {
}
for _, test := range []struct {
filters Filters
total int64
ids []string
}{
{Filters{Page: 1, PageSize: 2}, 5, []string{"5", "4"}},
{Filters{Page: 2, PageSize: 2}, 5, []string{"3", "2"}},
{Filters{Page: 3, PageSize: 2}, 5, []string{"1"}},
{Filters{Page: 1, PageSize: 2, Sort: "title"}, 5, []string{"2", "5"}},
{Filters{Page: 1, PageSize: 8, Query: "graph"}, 2, []string{"3", "1"}},
{Filters{Page: 1, PageSize: 8, Query: "graph OR zebra"}, 0, []string{}},
{Filters{Page: 1, PageSize: 8, Query: "Ada Smith", SearchIn: "author"}, 1, []string{"1"}},
{Filters{Page: 1, PageSize: 8, Topic: "cs.LG"}, 1, []string{"1"}},
{Filters{Page: 1, PageSize: 8, Topic: "cs.DC", Query: "graph"}, 1, []string{"3"}},
{Filters{Page: 1, PageSize: 8, Query: `x'); DROP TABLE papers;--`}, 0, []string{}},
{Filters{PageSize: 2}, []string{"5", "4"}},
{Filters{PageSize: 2, Sort: "title"}, []string{"2", "5"}},
{Filters{PageSize: 8, Query: "graph"}, []string{"3", "1"}},
{Filters{PageSize: 8, Query: "graph OR zebra"}, []string{}},
{Filters{PageSize: 8, Query: "Ada Smith", SearchIn: "author"}, []string{"1"}},
{Filters{PageSize: 8, Topic: "cs.LG"}, []string{"1"}},
{Filters{PageSize: 8, Topic: "cs.DC", Query: "graph"}, []string{"3"}},
{Filters{PageSize: 8, Query: `x'); DROP TABLE papers;--`}, []string{}},
} {
page, err := r.List(ctx, test.filters)
if err != nil {
t.Fatalf("%+v: %v", test.filters, err)
}
if page.Total != test.total || !reflect.DeepEqual(paperIDs(page.Items), test.ids) {
t.Fatalf("%+v: total=%d ids=%v", test.filters, page.Total, paperIDs(page.Items))
if !reflect.DeepEqual(paperIDs(page.Items), test.ids) {
t.Fatalf("%+v: ids=%v", test.filters, paperIDs(page.Items))
}
if !reflect.DeepEqual(page.Topics, []string{"cs.AI", "cs.DC", "cs.LG"}) {
t.Fatalf("mixed tag namespaces: %v", page.Topics)
}
if page.Page != test.filters.Page || page.PageSize != test.filters.PageSize {
if page.PageSize != test.filters.PageSize {
t.Fatal("lost paging metadata")
}
}
@@ -143,7 +140,7 @@ func TestPostgresCorpusIsReadOnlyAndHTTPWorks(t *testing.T) {
Register(mux, r, map[string]string{"thesisly": "http://localhost:17322/auth/callback"})
w := request(mux, "GET", "/projects/thesisly/research/papers?q=graph")
var page Page
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &page) != nil || page.Total != 2 {
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &page) != nil || len(page.Items) != 2 {
t.Fatalf("SQL-backed HTTP list: %d %s", w.Code, w.Body.String())
}
w = request(mux, "GET", "/projects/thesisly/research/papers/1")
@@ -179,8 +176,174 @@ func TestPostgresPublishedTextCodec(t *testing.T) {
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &decoded) != nil || decoded.Title != p.Title || decoded.Abstract == nil || *decoded.Abstract != *p.Abstract {
t.Fatalf("HTTP lost original control characters: %d %s", w.Code, w.Body.String())
}
page, err := r.List(ctx, Filters{Page: 1, PageSize: 8, Query: "graph"})
if err != nil || page.Total != 2 || len(page.Items) != 2 || page.Items[1].Title != p.Title {
page, err := r.List(ctx, Filters{PageSize: 8, Query: "graph"})
if err != nil || len(page.Items) != 2 || len(page.Items) != 2 || page.Items[1].Title != p.Title {
t.Fatalf("encoded corpus search: %+v %v", page, err)
}
}
func TestPostgresCursorsTraverseBothDirections(t *testing.T) {
r, ctx := integrationRepository(t)
for _, sort := range []string{"recent", "title"} {
f := Filters{Sort: sort, PageSize: 1}
seen := []string{}
cursors := []string{""}
for {
page, err := r.List(ctx, f)
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 {
t.Fatalf("unexpected page %+v", page)
}
seen = append(seen, page.Items[0].ID)
if len(seen) > 5 {
t.Fatal("cursor cycle")
}
if len(seen) > 1 {
back := f
back.Cursor = page.PreviousCursor
prior, err := r.List(ctx, back)
if err != nil || len(prior.Items) != 1 || prior.Items[0].ID != seen[len(seen)-2] {
t.Fatalf("reverse cursor: %+v %v", prior, err)
}
} else if page.PreviousCursor != "" {
t.Fatal("first page has previous")
}
if page.NextCursor == "" {
break
}
f.Cursor = page.NextCursor
cursors = append(cursors, f.Cursor)
}
want := []string{"5", "4", "3", "2", "1"}
if sort == "title" {
want = []string{"2", "5", "1", "3", "4"}
}
if !reflect.DeepEqual(seen, want) {
t.Fatalf("%s: %v", sort, seen)
}
f.Cursor = cursors[1]
f.Query = "different"
if _, err := r.List(ctx, f); err == nil {
t.Fatal("cursor reused across filters")
}
}
}
func TestPostgresEncodedTitleCursors(t *testing.T) {
legacy, ctx := integrationRepository(t)
r, err := NewPostgresRepository(legacy.db, "research_encoded_fixture")
if err != nil {
t.Fatal(err)
}
if err = r.Check(ctx); err != nil {
t.Fatal(err)
}
f := Filters{Sort: "title", PageSize: 1}
seen := []string{}
for {
page, err := r.List(ctx, f)
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 {
t.Fatalf("invalid encoded title page %+v", page)
}
seen = append(seen, page.Items[0].ID)
if len(seen) > 5 {
t.Fatal("cursor cycle")
}
if page.NextCursor == "" {
break
}
f.Cursor = page.NextCursor
}
if !reflect.DeepEqual(seen, []string{"2", "5", "1", "3", "4"}) {
t.Fatalf("encoded key paging %v", seen)
}
}
func TestPostgresBoundedProbeDoesNotTruncateSparseMatches(t *testing.T) {
legacy, ctx := integrationRepository(t)
r, err := NewPostgresRepository(legacy.db, "research_sparse_fixture")
if err != nil {
t.Fatal(err)
}
if err = r.Check(ctx); err != nil {
t.Fatal(err)
}
for _, size := range []int{1, 2, 3} {
f := Filters{Query: "graph", PageSize: size}
seen := []string{}
var previous []string
for {
page, err := r.List(ctx, f)
if err != nil {
t.Fatal(err)
}
ids := paperIDs(page.Items)
if len(ids) == 0 || len(ids) > size {
t.Fatalf("invalid sparse page %+v", page)
}
if previous != nil {
back := f
back.Cursor = page.PreviousCursor
prior, err := r.List(ctx, back)
if err != nil || !reflect.DeepEqual(paperIDs(prior.Items), previous) {
t.Fatalf("reverse gap %+v %v", prior, err)
}
}
seen = append(seen, ids...)
if len(seen) > 5 {
t.Fatal("cursor cycle")
}
previous = ids
if page.NextCursor == "" {
break
}
f.Cursor = page.NextCursor
}
if !reflect.DeepEqual(seen, []string{"10001", "10000", "8000", "3", "1"}) {
t.Fatalf("size=%d lost sparse matches %v", size, seen)
}
}
}
func TestPostgresTopicCursorSkipsDuplicateEvidence(t *testing.T) {
legacy, ctx := integrationRepository(t)
r, err := NewPostgresRepository(legacy.db, "research_sparse_fixture")
if err != nil {
t.Fatal(err)
}
f := Filters{Topic: "cs.LG", PageSize: 1}
seen := []string{}
for {
page, err := r.List(ctx, f)
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 {
t.Fatalf("invalid topic page %+v", page)
}
seen = append(seen, page.Items[0].ID)
if len(seen) > 3 {
t.Fatal("cursor cycle")
}
if len(seen) > 1 {
back := f
back.Cursor = page.PreviousCursor
prior, err := r.List(ctx, back)
if err != nil || len(prior.Items) != 1 || prior.Items[0].ID != seen[len(seen)-2] {
t.Fatalf("reverse topic %+v %v", prior, err)
}
}
if page.NextCursor == "" {
break
}
f.Cursor = page.NextCursor
}
if !reflect.DeepEqual(seen, []string{"10001", "10000", "1"}) {
t.Fatalf("duplicate evidence or lost topic matches %v", seen)
}
}