diff --git a/lib/gcpspanner/client.go b/lib/gcpspanner/client.go index f3209290..79d597dc 100644 --- a/lib/gcpspanner/client.go +++ b/lib/gcpspanner/client.go @@ -471,14 +471,12 @@ func (c *entityWriter[M, ExternalStruct, SpannerStruct, ExternalKey]) updateWith } // removableEntityMapper extends writeableEntityMapper with the ability to remove an entity. -// nolint:unused // TODO: Remove nolint directive once the interface is used. type removableEntityMapper[ExternalStruct any, SpannerStruct any, ExternalKey any] interface { writeableEntityMapper[ExternalStruct, SpannerStruct, ExternalKey] DeleteKey(ExternalKey) spanner.Key } // entityRemover is a basic client for removing any row from the database. -// nolint:unused // TODO: Remove nolint directive once the type is used. type entityRemover[ M removableEntityMapper[ExternalStruct, SpannerStruct, ExternalKey], ExternalStruct any, @@ -488,7 +486,6 @@ type entityRemover[ } // remove performs an delete operation on an entity. -// nolint:unused // TODO: Remove nolint directive once the method is used. func (c *entityRemover[M, ExternalStruct, SpannerStruct, ExternalKey]) remove(ctx context.Context, input ExternalStruct) error { _, err := c.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { @@ -559,7 +556,6 @@ func newEntityReader[ return &entityReader[M, ExternalStruct, SpannerStruct, ExternalKey]{c} } -// nolint:unused // TODO: Remove nolint directive once the method is used. func newEntityRemover[ M removableEntityMapper[ExternalStruct, SpannerStruct, ExternalKey], SpannerStruct any, diff --git a/lib/gcpspanner/user_search_bookmarks.go b/lib/gcpspanner/user_search_bookmarks.go index 3eee6e9f..83ea79c8 100644 --- a/lib/gcpspanner/user_search_bookmarks.go +++ b/lib/gcpspanner/user_search_bookmarks.go @@ -14,6 +14,13 @@ package gcpspanner +import ( + "context" + "fmt" + + "cloud.google.com/go/spanner" +) + // UserSavedSearchBookmark represents a user's bookmark for a saved search. type UserSavedSearchBookmark struct { UserID string `spanner:"UserID"` @@ -21,3 +28,56 @@ type UserSavedSearchBookmark struct { } const userSavedSearchBookmarksTable = "UserSavedSearchBookmarks" + +// Implements the entityMapper interface for UserSavedSearchBookmark. +type userSavedSearchBookmarkMapper struct{} + +func (m userSavedSearchBookmarkMapper) Table() string { + return userSavedSearchBookmarksTable +} + +type userSavedSearchBookmarkKey struct { + UserSavedSearchBookmark +} + +func (m userSavedSearchBookmarkMapper) GetKey( + in UserSavedSearchBookmark) userSavedSearchBookmarkKey { + return userSavedSearchBookmarkKey{ + UserSavedSearchBookmark: in, + } +} + +func (m userSavedSearchBookmarkMapper) Merge( + _ UserSavedSearchBookmark, existing UserSavedSearchBookmark) UserSavedSearchBookmark { + return existing +} + +func (m userSavedSearchBookmarkMapper) SelectOne( + key userSavedSearchBookmarkKey) spanner.Statement { + stmt := spanner.NewStatement(fmt.Sprintf(` + SELECT + SavedSearchID, UserID + FROM %s + WHERE UserID = @userID AND SavedSearchID = @savedSearchID + LIMIT 1`, + m.Table())) + parameters := map[string]interface{}{ + "userID": key.UserID, + "savedSearchID": key.SavedSearchID, + } + stmt.Params = parameters + + return stmt +} + +func (m userSavedSearchBookmarkMapper) DeleteKey(key userSavedSearchBookmarkKey) spanner.Key { + return spanner.Key{key.UserID, key.SavedSearchID} +} + +func (c *Client) AddUserSearchBookmark(ctx context.Context, req UserSavedSearchBookmark) error { + return newEntityWriter[userSavedSearchBookmarkMapper](c).upsert(ctx, req) +} + +func (c *Client) DeleteUserSearchBookmark(ctx context.Context, req UserSavedSearchBookmark) error { + return newEntityRemover[userSavedSearchBookmarkMapper](c).remove(ctx, req) +} diff --git a/lib/gcpspanner/user_search_bookmarks_test.go b/lib/gcpspanner/user_search_bookmarks_test.go new file mode 100644 index 00000000..8417df86 --- /dev/null +++ b/lib/gcpspanner/user_search_bookmarks_test.go @@ -0,0 +1,111 @@ +// Copyright 2024 Google LLC +// +// 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 gcpspanner + +import ( + "context" + "testing" + + "cloud.google.com/go/spanner" +) + +func TestUserSearchBookmark(t *testing.T) { + restartDatabaseContainer(t) + ctx := context.Background() + + savedSearchID, err := spannerClient.CreateNewUserSavedSearch(ctx, CreateUserSavedSearchRequest{ + Name: "my little search", + Query: "group:css", + OwnerUserID: "userID1", + }) + if err != nil { + t.Errorf("expected nil error. received %s", err) + } + if savedSearchID == nil { + t.Fatal("expected non-nil id.") + } + + const testUser = "test-user" + + // user initially does not have a bookmark + expectedSavedSearch := &UserSavedSearch{ + IsBookmarked: valuePtr(false), + Role: nil, + SavedSearch: SavedSearch{ + ID: *savedSearchID, + Name: "my little search", + Query: "group:css", + Scope: "USER_PUBLIC", + AuthorID: "userID1", + // Don't actually compare the last two values. + CreatedAt: spanner.CommitTimestamp, + UpdatedAt: spanner.CommitTimestamp, + }, + } + actual, err := spannerClient.GetUserSavedSearch(ctx, *savedSearchID, valuePtr(testUser)) + if err != nil { + t.Errorf("expected nil error. received %s", err) + } + if !userSavedSearchEquality(expectedSavedSearch, actual) { + t.Errorf("different saved searches\nexpected: %+v\nreceived: %v", expectedSavedSearch, actual) + } + + // user can successfully have a bookmark added + expectedSavedSearchAfter := &UserSavedSearch{ + IsBookmarked: valuePtr(true), + Role: nil, + SavedSearch: SavedSearch{ + ID: *savedSearchID, + Name: "my little search", + Query: "group:css", + Scope: "USER_PUBLIC", + AuthorID: "userID1", + // Don't actually compare the last two values. + CreatedAt: spanner.CommitTimestamp, + UpdatedAt: spanner.CommitTimestamp, + }, + } + err = spannerClient.AddUserSearchBookmark(ctx, UserSavedSearchBookmark{ + UserID: testUser, + SavedSearchID: *savedSearchID, + }) + if err != nil { + t.Errorf("expected nil error. received %s", err) + } + actual, err = spannerClient.GetUserSavedSearch(ctx, *savedSearchID, valuePtr(testUser)) + if err != nil { + t.Errorf("expected nil error. received %s", err) + } + if !userSavedSearchEquality(expectedSavedSearchAfter, actual) { + t.Errorf("different saved searches\nexpected: %+v\nreceived: %v", expectedSavedSearchAfter, actual) + } + + // user can successfully have a bookmark removed + err = spannerClient.DeleteUserSearchBookmark(ctx, UserSavedSearchBookmark{ + UserID: testUser, + SavedSearchID: *savedSearchID, + }) + if err != nil { + t.Errorf("expected nil error. received %s", err) + } + actual, err = spannerClient.GetUserSavedSearch(ctx, *savedSearchID, valuePtr(testUser)) + if err != nil { + t.Errorf("expected nil error. received %s", err) + } + if !userSavedSearchEquality(expectedSavedSearch, actual) { + t.Errorf("different saved searches\nexpected: %+v\nreceived: %v", expectedSavedSearch, actual) + } + +}