Skip to content

Commit e18ab7c

Browse files
authored
Spanner logic to delete a single user saved search (#1244)
This change adds the logic to delete a single saved search. This change adds removeUserSavedSearchMapper which implements the removableEntityMapper interface. Prior the deletion, it does a role check for the user id. Other changes: - Slight changes to removableEntityMapper interface Fixes #805
1 parent d4ef908 commit e18ab7c

File tree

4 files changed

+172
-2
lines changed

4 files changed

+172
-2
lines changed

lib/gcpspanner/client.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,8 +612,10 @@ func (c *entityWriter[M, ExternalStruct, SpannerStruct, ExternalKey]) updateWith
612612

613613
// removableEntityMapper extends writeableEntityMapper with the ability to remove an entity.
614614
type removableEntityMapper[ExternalStruct any, SpannerStruct any, ExternalKey any] interface {
615-
writeableEntityMapper[ExternalStruct, SpannerStruct, ExternalKey]
615+
readableEntityMapper[ExternalStruct, SpannerStruct, ExternalKey]
616+
GetKey(ExternalStruct) ExternalKey
616617
DeleteKey(ExternalKey) spanner.Key
618+
Table() string
617619
}
618620

619621
// entityRemover is a basic client for removing any row from the database.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
"log/slog"
20+
21+
"cloud.google.com/go/spanner"
22+
)
23+
24+
// removeUserSavedSearchMapper implements removableEntityMapper.
25+
type removeUserSavedSearchMapper struct{}
26+
27+
func (m removeUserSavedSearchMapper) Table() string { return savedSearchesTable }
28+
29+
func (m removeUserSavedSearchMapper) GetKey(in DeleteUserSavedSearchRequest) removeUserSavedSearchMapperKey {
30+
return removeUserSavedSearchMapperKey{
31+
ID: in.SavedSearchID,
32+
UserID: in.RequestingUserID,
33+
}
34+
}
35+
36+
type removeUserSavedSearchMapperKey struct {
37+
ID string
38+
UserID string
39+
}
40+
41+
func (m removeUserSavedSearchMapper) SelectOne(key removeUserSavedSearchMapperKey) spanner.Statement {
42+
return authenticatedUserSavedSearchMapper{}.SelectOne(
43+
authenticatedUserSavedSearchMapperKey(key))
44+
}
45+
46+
func (m removeUserSavedSearchMapper) DeleteKey(key removeUserSavedSearchMapperKey) spanner.Key {
47+
return spanner.Key{key.ID}
48+
}
49+
50+
// DeleteUserSavedSearchRequest contains the request parameters for DeleteUserSavedSearch.
51+
type DeleteUserSavedSearchRequest struct {
52+
RequestingUserID string
53+
SavedSearchID string
54+
}
55+
56+
// DeleteUserSavedSearch deletes a user's saved search.
57+
func (c *Client) DeleteUserSavedSearch(ctx context.Context, req DeleteUserSavedSearchRequest) error {
58+
_, err := c.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
59+
// 1. Check if the user has permission to delete (OWNER role)
60+
err := c.checkForSavedSearchRole(ctx, txn, SavedSearchOwner, req.RequestingUserID, req.SavedSearchID)
61+
if err != nil {
62+
return err
63+
}
64+
65+
// 2. Read and update the existing saved search
66+
err = newEntityRemover[removeUserSavedSearchMapper, UserSavedSearch](c).removeWithTransaction(ctx, txn, req)
67+
if err != nil {
68+
slog.ErrorContext(ctx, "failed to update the saved search", "error", err)
69+
70+
return err
71+
}
72+
73+
return nil
74+
})
75+
if err != nil {
76+
return err
77+
}
78+
79+
return nil
80+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
"testing"
21+
22+
"cloud.google.com/go/spanner"
23+
)
24+
25+
func TestDeleteUserSavedSearch(t *testing.T) {
26+
restartDatabaseContainer(t)
27+
ctx := context.Background()
28+
29+
savedSearchID, err := spannerClient.CreateNewUserSavedSearch(ctx, CreateUserSavedSearchRequest{
30+
Name: "my little search",
31+
Query: "group:css",
32+
OwnerUserID: "userID1",
33+
Description: valuePtr("desc"),
34+
})
35+
if err != nil {
36+
t.Errorf("expected nil error. received %s", err)
37+
}
38+
if savedSearchID == nil {
39+
t.Error("expected non-nil id.")
40+
}
41+
42+
t.Run("non owner cannot delete search", func(t *testing.T) {
43+
err := spannerClient.DeleteUserSavedSearch(ctx, DeleteUserSavedSearchRequest{
44+
SavedSearchID: *savedSearchID,
45+
RequestingUserID: "userID2",
46+
})
47+
if !errors.Is(err, ErrMissingRequiredRole) {
48+
t.Errorf("expected ErrMissingRequiredRole. received %s", err)
49+
}
50+
51+
expectedSavedSearch := &UserSavedSearch{
52+
IsBookmarked: nil,
53+
Role: nil,
54+
SavedSearch: SavedSearch{
55+
ID: *savedSearchID,
56+
Name: "my little search",
57+
Query: "group:css",
58+
Scope: "USER_PUBLIC",
59+
AuthorID: "userID1",
60+
Description: valuePtr("desc"),
61+
// Don't actually compare the last two values.
62+
CreatedAt: spanner.CommitTimestamp,
63+
UpdatedAt: spanner.CommitTimestamp,
64+
},
65+
}
66+
actual, err := spannerClient.GetUserSavedSearch(ctx, *savedSearchID, nil)
67+
if err != nil {
68+
t.Errorf("expected nil error. received %s", err)
69+
}
70+
if !userSavedSearchEquality(expectedSavedSearch, actual) {
71+
t.Errorf("different saved searches\nexpected: %+v\nreceived: %v", expectedSavedSearch, actual)
72+
}
73+
})
74+
75+
t.Run("owner can delete search", func(t *testing.T) {
76+
err := spannerClient.DeleteUserSavedSearch(ctx, DeleteUserSavedSearchRequest{
77+
SavedSearchID: *savedSearchID,
78+
RequestingUserID: "userID1",
79+
})
80+
if !errors.Is(err, nil) {
81+
t.Errorf("expected nil error. received %s", err)
82+
}
83+
_, err = spannerClient.GetUserSavedSearch(ctx, *savedSearchID, nil)
84+
if !errors.Is(err, ErrQueryReturnedNoResults) {
85+
t.Errorf("expected ErrQueryReturnedNoResults. received %s", err)
86+
}
87+
})
88+
}

lib/gcpspanner/user_search_bookmarks.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,5 @@ func (c *Client) AddUserSearchBookmark(ctx context.Context, req UserSavedSearchB
7979
}
8080

8181
func (c *Client) DeleteUserSearchBookmark(ctx context.Context, req UserSavedSearchBookmark) error {
82-
return newEntityRemover[userSavedSearchBookmarkMapper](c).remove(ctx, req)
82+
return newEntityRemover[userSavedSearchBookmarkMapper, UserSavedSearchBookmark](c).remove(ctx, req)
8383
}

0 commit comments

Comments
 (0)