Skip to content

Commit 3383900

Browse files
authored
Add Spanner logic for paginated bookmarked searches (#1292)
Implements Spanner logic for the `/v1/users/me/saved-searches` (listUserSavedSearches) endpoint, retrieving paginated bookmarked searches for an authenticated user. * This method is called with the authenticated user's ID. * It returns only bookmarked saved searches. As a reminder: a user can bookmark a saved search by:: 1. Creating a saved search automatically bookmarks it for the owner (similar to [Issue Tracker hotlists](https://developers.google.com/issue-tracker/guides/work-with-hotlist#:~:text=Hotlists%20that%20you%20own%20are%20starred%20automatically.)). 2. Users can bookmark others' saved searches (read-only).
1 parent 160d595 commit 3383900

File tree

2 files changed

+444
-0
lines changed

2 files changed

+444
-0
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package gcpspanner
16+
17+
import (
18+
"context"
19+
"errors"
20+
21+
"cloud.google.com/go/spanner"
22+
"google.golang.org/api/iterator"
23+
)
24+
25+
const (
26+
listUserSavedSearchesBaseRawTemplate = `
27+
SELECT
28+
ID,
29+
Name,
30+
Description,
31+
Query,
32+
Scope,
33+
AuthorID,
34+
CreatedAt,
35+
UpdatedAt,
36+
r.UserRole AS Role,
37+
CASE
38+
WHEN b.UserID IS NOT NULL THEN TRUE
39+
ELSE FALSE
40+
END AS IsBookmarked
41+
FROM SavedSearches s
42+
LEFT JOIN
43+
SavedSearchUserRoles r ON s.ID = r.SavedSearchID AND r.UserID = @userID
44+
JOIN
45+
UserSavedSearchBookmarks b ON s.ID = b.SavedSearchID AND b.UserID = @userID
46+
WHERE
47+
s.Scope = 'USER_PUBLIC'
48+
{{ if .PageFilter }}
49+
{{ .PageFilter }}
50+
{{ end }}
51+
ORDER BY Name ASC, ID ASC LIMIT @pageSize
52+
`
53+
54+
// Because the name might not be unique, we must allow tie breaking by ID.
55+
commonListUserSavedSearchesPaginationRawTemplate = `
56+
AND (s.Name > @lastName OR (s.Name = @lastName AND s.ID > @lastID))`
57+
)
58+
59+
// nolint: gochecknoglobals // WONTFIX. Compile the template once at startup. Startup fails if invalid.
60+
var (
61+
// listUserSavedSearchesBaseTemplate is the compiled version of listUserSavedSearchesBaseRawTemplate.
62+
listUserSavedSearchesBaseTemplate BaseQueryTemplate
63+
)
64+
65+
func init() {
66+
listUserSavedSearchesBaseTemplate = NewQueryTemplate(listUserSavedSearchesBaseRawTemplate)
67+
}
68+
69+
// listUserSavedSearchTemplateData contains the variables for the listUserSavedSearchesBaseRawTemplate.
70+
type listUserSavedSearchTemplateData struct {
71+
PageFilter string
72+
}
73+
74+
// UserSavedSearchesPage contains the details for a page of UserSavedSearches.
75+
type UserSavedSearchesPage struct {
76+
NextPageToken *string
77+
Searches []UserSavedSearch
78+
}
79+
80+
// UserSavedSearchesCursor represents a point for resuming queries based on
81+
// the last date. Useful for pagination.
82+
type UserSavedSearchesCursor struct {
83+
LastID string `json:"last_id"`
84+
LastName string `json:"last_name"`
85+
}
86+
87+
// decodeUserSavedSearchesCursor provides a wrapper around the generic decodeCursor for UserSavedSearchesCursor.
88+
func decodeUserSavedSearchesCursor(
89+
cursor string) (*UserSavedSearchesCursor, error) {
90+
return decodeCursor[UserSavedSearchesCursor](cursor)
91+
}
92+
93+
// encodeUserSavedSearchesCursor providers a wrapper around the generic encodeCursor for UserSavedSearchesCursor.
94+
func encodeUserSavedSearchesCursor(id string, name string) string {
95+
return encodeCursor(UserSavedSearchesCursor{
96+
LastID: id,
97+
LastName: name,
98+
})
99+
}
100+
101+
func (c *Client) ListUserSavedSearches(
102+
ctx context.Context,
103+
userID string,
104+
pageSize int,
105+
pageToken *string) (*UserSavedSearchesPage, error) {
106+
params := map[string]interface{}{
107+
"userID": userID,
108+
"pageSize": pageSize,
109+
}
110+
111+
tmplData := listUserSavedSearchTemplateData{
112+
PageFilter: "",
113+
}
114+
115+
if pageToken != nil {
116+
cursor, err := decodeUserSavedSearchesCursor(*pageToken)
117+
if err != nil {
118+
return nil, errors.Join(ErrInternalQueryFailure, err)
119+
}
120+
params["lastName"] = cursor.LastName
121+
params["lastID"] = cursor.LastID
122+
tmplData.PageFilter = commonListUserSavedSearchesPaginationRawTemplate
123+
}
124+
125+
tmpl := listUserSavedSearchesBaseTemplate.Execute(tmplData)
126+
stmt := spanner.NewStatement(tmpl)
127+
stmt.Params = params
128+
129+
txn := c.Single()
130+
defer txn.Close()
131+
it := txn.Query(ctx, stmt)
132+
defer it.Stop()
133+
134+
var results []UserSavedSearch
135+
136+
for {
137+
row, err := it.Next()
138+
if errors.Is(err, iterator.Done) {
139+
break
140+
}
141+
if err != nil {
142+
return nil, errors.Join(ErrInternalQueryFailure, err)
143+
}
144+
var result UserSavedSearch
145+
if err := row.ToStruct(&result); err != nil {
146+
return nil, err
147+
}
148+
results = append(results, result)
149+
}
150+
page := &UserSavedSearchesPage{
151+
Searches: results,
152+
NextPageToken: nil,
153+
}
154+
155+
if len(results) == pageSize {
156+
lastResult := results[len(results)-1]
157+
token := encodeUserSavedSearchesCursor(lastResult.ID, lastResult.Name)
158+
page.NextPageToken = &token
159+
}
160+
161+
return page, nil
162+
}

0 commit comments

Comments
 (0)