Skip to content

Commit

Permalink
Add database method to create user saved search (#815)
Browse files Browse the repository at this point in the history
For creating:
- In the same transaction it will check:
    - If the user does not owned too many saved searches.
    - Add the user as an admin
    - Add the new saved search as a bookmark

We also introduce the search config struct which holds an app wide configuration for the max number of saved searches a user can have.

Fixes: #804
  • Loading branch information
jcscottiii authored Oct 24, 2024
1 parent 0ccb2a5 commit 187bbc2
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 0 deletions.
9 changes: 9 additions & 0 deletions lib/gcpspanner/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,16 @@ var ErrInvalidCursorFormat = errors.New("invalid cursor format")
type Client struct {
*spanner.Client
featureSearchQuery FeatureSearchBaseQuery
searchCfg searchConfig
}

// searchConfig holds the application configuation for the saved search feature.
type searchConfig struct {
maxOwnedSearchesPerUser uint32
}

const defaultMaxOwnedSearchesPerUser = 25

// NewSpannerClient returns a Client for the Google Spanner service.
func NewSpannerClient(projectID string, instanceID string, name string) (*Client, error) {
if projectID == "" || instanceID == "" || name == "" {
Expand All @@ -66,6 +74,7 @@ func NewSpannerClient(projectID string, instanceID string, name string) (*Client
return &Client{
client,
GCPFeatureSearchBaseQuery{},
searchConfig{maxOwnedSearchesPerUser: defaultMaxOwnedSearchesPerUser},
}, nil
}

Expand Down
123 changes: 123 additions & 0 deletions lib/gcpspanner/create_user_saved_search.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// 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"
"errors"
"fmt"

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

// CreateUserSavedSearchRequest is the request to create a new user saved search.
type CreateUserSavedSearchRequest struct {
Name string
Query string
OwnerUserID string
}

var (
// ErrOwnerSavedSearchLimitExceeded indicates that the user already has
// reached the limit of saved searches that a given user can own.
ErrOwnerSavedSearchLimitExceeded = errors.New("saved search limit reached")
)

// CreateNewUserSavedSearch creates a new user-owned saved search.
// It returns the ID of the newly created saved search if successful.
func (c *Client) CreateNewUserSavedSearch(
ctx context.Context,
newSearch CreateUserSavedSearchRequest) (*string, error) {
id := uuid.NewString()
_, err := c.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
// 1. Read the current count of owned searches
var count int64
stmt := spanner.Statement{
SQL: fmt.Sprintf(`SELECT COUNT(*)
FROM %s
WHERE UserID = @OwnerID AND UserRole = @Role`, savedSearchUserRolesTable),
Params: map[string]interface{}{
"OwnerID": newSearch.OwnerUserID,
"Role": SavedSearchOwner,
},
}
row, err := txn.Query(ctx, stmt).Next()
if err != nil {
return err
}
if err := row.Columns(&count); err != nil {
return err
}

// 2. Check against the limit
if count >= int64(c.searchCfg.maxOwnedSearchesPerUser) {
return ErrOwnerSavedSearchLimitExceeded
}

var mutations []*spanner.Mutation
// TODO: In the future, look into using an entityMapper for SavedSearch.
// Then, we can use createInsertMutation.
m1, err := spanner.InsertStruct(savedSearchesTable, SavedSearch{
ID: id,
Name: newSearch.Name,
Query: newSearch.Query,
Scope: UserPublicScope,
AuthorID: newSearch.OwnerUserID,
CreatedAt: spanner.CommitTimestamp,
UpdatedAt: spanner.CommitTimestamp,
})
if err != nil {
return errors.Join(ErrInternalQueryFailure, err)
}
mutations = append(mutations, m1)

// TODO: In the future, look into using an entityMapper for SavedSearchUserRole.
// Then, we can use createInsertMutation.
m2, err := spanner.InsertStruct(savedSearchUserRolesTable, SavedSearchUserRole{
SavedSearchID: id,
UserID: newSearch.OwnerUserID,
UserRole: SavedSearchOwner,
})
if err != nil {
return errors.Join(ErrInternalQueryFailure, err)
}
mutations = append(mutations, m2)

// TODO: In the future, look into using an entityMapper for UserSavedSearchBookmark.
// Then, we can use createInsertMutation.
m3, err := spanner.InsertStruct(userSavedSearchBookmarksTable, UserSavedSearchBookmark{
SavedSearchID: id,
UserID: newSearch.OwnerUserID,
})
if err != nil {
return errors.Join(ErrInternalQueryFailure, err)
}
mutations = append(mutations, m3)

err = txn.BufferWrite(mutations)
if err != nil {
return errors.Join(ErrInternalQueryFailure, err)
}

return nil
})

if err != nil {
return nil, err
}

return &id, nil
}
67 changes: 67 additions & 0 deletions lib/gcpspanner/create_user_saved_search_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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"
"errors"
"testing"
)

func TestCreateNewUserSavedSearch(t *testing.T) {
restartDatabaseContainer(t)
ctx := context.Background()
// Reset the max to 2.
spannerClient.searchCfg.maxOwnedSearchesPerUser = 2

t.Run("create fails after reaching limit", func(t *testing.T) {
savedSearchID1, 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 savedSearchID1 == nil {
t.Error("expected non-nil id.")
}

savedSearchID2, err := spannerClient.CreateNewUserSavedSearch(ctx, CreateUserSavedSearchRequest{
Name: "my little search part 2",
Query: "group:avif",
OwnerUserID: "userID1",
})
if err != nil {
t.Errorf("expected nil error. received %s", err)
}
if savedSearchID2 == nil {
t.Error("expected non-nil id.")
}

savedSearchID3, err := spannerClient.CreateNewUserSavedSearch(ctx, CreateUserSavedSearchRequest{
Name: "my little search part 3",
Query: "name:subgrid",
OwnerUserID: "userID1",
})
if !errors.Is(err, ErrOwnerSavedSearchLimitExceeded) {
t.Errorf("unexpected error. received %v", err)
}
if savedSearchID3 != nil {
t.Error("expected nil id.")
}
})

}
9 changes: 9 additions & 0 deletions lib/gcpspanner/saved_search_user_roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,12 @@ const (
// SavedSearchOwner indicates the user owns the saved search query.
SavedSearchOwner SavedSearchRole = "OWNER"
)

const savedSearchUserRolesTable = "SavedSearchUserRoles"

// SavedSearchUserRole represents a user's role in relation to a saved search.
type SavedSearchUserRole struct {
SavedSearchID string `spanner:"SavedSearchID"`
UserID string `spanner:"UserID"`
UserRole SavedSearchRole `spanner:"UserRole"`
}
17 changes: 17 additions & 0 deletions lib/gcpspanner/saved_searches.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,27 @@

package gcpspanner

import (
"time"
)

// SavedSearchScope represents the scope of a saved search.
type SavedSearchScope string

const (
// UserPublicScope indicates that this is user created saved search meant to be publicly accessible.
UserPublicScope SavedSearchScope = "USER_PUBLIC"
)

const savedSearchesTable = "SavedSearches"

// SavedSearch represents a saved search row in the SavedSearches table.
type SavedSearch struct {
ID string `spanner:"ID"`
Name string `spanner:"Name"`
Query string `spanner:"Query"`
Scope SavedSearchScope `spanner:"Scope"`
AuthorID string `spanner:"AuthorID"`
CreatedAt time.Time `spanner:"CreatedAt"`
UpdatedAt time.Time `spanner:"UpdatedAt"`
}
8 changes: 8 additions & 0 deletions lib/gcpspanner/user_search_bookmarks.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,11 @@
// limitations under the License.

package gcpspanner

// UserSavedSearchBookmark represents a user's bookmark for a saved search.
type UserSavedSearchBookmark struct {
UserID string `spanner:"UserID"`
SavedSearchID string `spanner:"SavedSearchID"`
}

const userSavedSearchBookmarksTable = "UserSavedSearchBookmarks"

0 comments on commit 187bbc2

Please sign in to comment.