Skip to content

Commit

Permalink
Add database method to get a user saved search (#816)
Browse files Browse the repository at this point in the history
For retrieving it has two modes:
1. unauthenticated:
    - these users can get the searches from all seaches with USER_PUBLIC but it has no user role or bookmark (because there is no user to check against)
2. authenticated:
    - these users can get the searches from all seaches with USER_PUBLIC and also gets the role and bookmark if the user has either.

Fixes: #814
  • Loading branch information
jcscottiii authored Oct 25, 2024
1 parent 187bbc2 commit daddb3a
Show file tree
Hide file tree
Showing 2 changed files with 260 additions and 0 deletions.
129 changes: 129 additions & 0 deletions lib/gcpspanner/get_user_saved_search.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// 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"
"fmt"

"cloud.google.com/go/spanner"
)

// UserSavedSearch represents a SavedSearch and a user's attributes related to that SavedSearch.
type UserSavedSearch struct {
SavedSearch
// Role will be nil if the user is not authenticated.
Role *string `spanner:"Role"`
// IsBookmarked will be nil if the user is not authenticated.
IsBookmarked *bool `spanner:"IsBookmarked"`
}

// unauthenticatedUserSavedSearchMapper contains the entityMapper implementation for an unauthenticated user.
type unauthenticatedUserSavedSearchMapper struct{}

func (m unauthenticatedUserSavedSearchMapper) SelectOne(
key string) spanner.Statement {
stmt := spanner.NewStatement(fmt.Sprintf(`
SELECT
ID,
Name,
Query,
Scope,
AuthorID,
CreatedAt,
UpdatedAt
FROM %s
WHERE
ID = @id
AND Scope = 'USER_PUBLIC'
LIMIT 1`,
savedSearchesTable))
parameters := map[string]interface{}{
"id": key,
}
stmt.Params = parameters

return stmt
}

// authenticatedUserSavedSearchMapper contains the entityMapper implementation for an authenticated user.
type authenticatedUserSavedSearchMapper struct{}

type authenticatedUserSavedSearchMapperKey struct {
ID string
UserID string
}

func (m authenticatedUserSavedSearchMapper) SelectOne(
key authenticatedUserSavedSearchMapperKey) spanner.Statement {
stmt := spanner.NewStatement(fmt.Sprintf(`
SELECT
ID,
Name,
Query,
Scope,
AuthorID,
CreatedAt,
UpdatedAt,
r.UserRole AS Role,
CASE
WHEN b.UserID IS NOT NULL THEN TRUE
ELSE FALSE
END AS IsBookmarked
FROM %s s
LEFT JOIN
SavedSearchUserRoles r ON s.ID = r.SavedSearchID AND r.UserID = @userID
LEFT JOIN
UserSavedSearchBookmarks b ON s.ID = b.SavedSearchID AND b.UserID = @userID
WHERE
s.ID = @id
AND s.Scope = 'USER_PUBLIC'
LIMIT 1`,
savedSearchesTable))
parameters := map[string]interface{}{
"id": key.ID,
"userID": key.UserID,
}
stmt.Params = parameters

return stmt
}

func (c *Client) GetUserSavedSearch(
ctx context.Context,
savedSearchID string,
authenticatedUserID *string) (*UserSavedSearch, error) {
if authenticatedUserID == nil {
// For an unauthenticated user, we only read the SavedSearches row then fill in the rest of
// UserSavedSearch struct with nil values.
row, err := newEntityReader[unauthenticatedUserSavedSearchMapper, SavedSearch, string](c).
readRowByKey(ctx, savedSearchID)
if err != nil {
return nil, err
}

return &UserSavedSearch{
SavedSearch: *row,
IsBookmarked: nil,
Role: nil,
}, nil
}

return newEntityReader[authenticatedUserSavedSearchMapper, UserSavedSearch, authenticatedUserSavedSearchMapperKey](c).
readRowByKey(ctx, authenticatedUserSavedSearchMapperKey{
UserID: *authenticatedUserID,
ID: savedSearchID,
})
}
131 changes: 131 additions & 0 deletions lib/gcpspanner/get_user_saved_search_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// 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"
"reflect"
"testing"

"cloud.google.com/go/spanner"
)

func TestGetUserSavedSearch(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.Error("expected non-nil id.")
}

t.Run("unauthenticated user can access public search", func(t *testing.T) {
expectedSavedSearch := &UserSavedSearch{
IsBookmarked: nil,
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, nil)
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)
}
})
t.Run("owner can access public search with roles and bookmark", func(t *testing.T) {
expectedSavedSearch := &UserSavedSearch{
IsBookmarked: valuePtr(true),
Role: valuePtr(string(SavedSearchOwner)),
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("userID1"))
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)
}
})

t.Run("other user can access public search. But unassigned roles and false bookmark", func(t *testing.T) {
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("otherUser"))
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)
}
})
}

func userSavedSearchEquality(left, right *UserSavedSearch) bool {
return (left == nil && right == nil) ||
(left != nil && right != nil &&
reflect.DeepEqual(left.IsBookmarked, right.IsBookmarked) &&
reflect.DeepEqual(left.Role, right.Role) &&
savedSearchEquality(left.SavedSearch, right.SavedSearch))

}

func savedSearchEquality(left, right SavedSearch) bool {
return left.ID == right.ID &&
left.Name == right.Name &&
left.Query == right.Query &&
left.Scope == right.Scope &&
left.AuthorID == right.AuthorID &&
// Just make sure the times are non zero.
!left.CreatedAt.IsZero() && !right.CreatedAt.IsZero() &&
!left.UpdatedAt.IsZero() && !right.UpdatedAt.IsZero()
}

0 comments on commit daddb3a

Please sign in to comment.