diff --git a/api/application.go b/api/application.go index 30e9109..3efe806 100644 --- a/api/application.go +++ b/api/application.go @@ -49,6 +49,10 @@ type ApplicationParams struct { // // example: 5 DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"` + // The sortKey for the application. Uses fractional indexing. + // + // example: a1 + SortKey string `form:"sortKey" query:"sortKey" json:"sortKey"` } // CreateApplication creates an application and returns the access token. @@ -91,6 +95,7 @@ func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) { Name: applicationParams.Name, Description: applicationParams.Description, DefaultPriority: applicationParams.DefaultPriority, + SortKey: applicationParams.SortKey, Token: auth.GenerateNotExistingToken(generateApplicationToken, a.applicationExists), UserID: auth.GetUserID(ctx), Internal: false, @@ -252,6 +257,9 @@ func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) { app.Description = applicationParams.Description app.Name = applicationParams.Name app.DefaultPriority = applicationParams.DefaultPriority + if applicationParams.SortKey != "" { + app.SortKey = applicationParams.SortKey + } if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success { return diff --git a/api/application_test.go b/api/application_test.go index 03f8157..117d09c 100644 --- a/api/application_test.go +++ b/api/application_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" "mime/multipart" "net/http/httptest" @@ -17,12 +18,14 @@ import ( "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) var ( firstApplicationToken = "Aaaaaaaaaaaaaaa" secondApplicationToken = "Abbbbbbbbbbbbbb" + thirdApplicationToken = "Acccccccccccccc" ) func TestApplicationSuite(t *testing.T) { @@ -45,8 +48,8 @@ var ( func (s *ApplicationSuite) BeforeTest(suiteName, testName string) { originalGenerateApplicationToken = generateApplicationToken originalGenerateImageName = generateImageName - generateApplicationToken = test.Tokens(firstApplicationToken, secondApplicationToken) - generateImageName = test.Tokens(firstApplicationToken[1:], secondApplicationToken[1:]) + generateApplicationToken = test.Tokens(firstApplicationToken, secondApplicationToken, thirdApplicationToken) + generateImageName = test.Tokens(firstApplicationToken[1:], secondApplicationToken[1:], thirdApplicationToken[1:]) mode.Set(mode.TestDev) s.recorder = httptest.NewRecorder() s.db = testdb.NewDB(s.T()) @@ -65,7 +68,7 @@ func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() { s.db.User(5) test.WithUser(s.ctx, 5) - s.withFormData("name=custom_name&description=description_text") + s.withFormData("name=custom_name&description=description_text&sortKey=a5") s.a.CreateApplication(s.ctx) expected := &model.Application{ @@ -74,6 +77,7 @@ func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() { UserID: 5, Name: "custom_name", Description: "description_text", + SortKey: "a5", } assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { @@ -91,8 +95,9 @@ func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation() Image: "asd", Internal: true, LastUsed: nil, + SortKey: "a1", } - test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null}`) + test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null, "sortKey":"a1"}`) } func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() { @@ -119,6 +124,7 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar Internal: true, Token: "token", Image: "adfdf", + SortKey: "a5", }) s.a.CreateApplication(s.ctx) @@ -131,6 +137,7 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar Description: "description", Internal: false, Image: "static/defaultapp.png", + SortKey: "a5", }) assert.Equal(s.T(), 200, s.recorder.Code) @@ -158,7 +165,7 @@ func (s *ApplicationSuite) Test_CreateApplication_onlyRequiredParameters() { s.withFormData("name=custom_name") s.a.CreateApplication(s.ctx) - expected := &model.Application{ID: 1, Token: firstApplicationToken, Name: "custom_name", UserID: 5} + expected := &model.Application{ID: 1, Token: firstApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0"} assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) { assert.Contains(s.T(), app, expected) @@ -174,11 +181,12 @@ func (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWithID() { s.a.CreateApplication(s.ctx) expected := &model.Application{ - ID: 1, - Token: firstApplicationToken, - Name: "custom_name", - Image: "static/defaultapp.png", - UserID: 5, + ID: 1, + Token: firstApplicationToken, + Name: "custom_name", + Image: "static/defaultapp.png", + UserID: 5, + SortKey: "a0", } assert.Equal(s.T(), 200, s.recorder.Code) test.BodyEquals(s.T(), expected, s.recorder) @@ -193,13 +201,53 @@ func (s *ApplicationSuite) Test_CreateApplication_withExistingToken() { s.a.CreateApplication(s.ctx) - expected := &model.Application{ID: 2, Token: secondApplicationToken, Name: "custom_name", UserID: 5} + expected := &model.Application{ID: 2, Token: secondApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0"} assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) { assert.Contains(s.T(), app, expected) } } +func (s *ApplicationSuite) Test_Sorting() { + s.db.User(5) + + test.WithUser(s.ctx, 5) + s.withFormData("name=one") + s.a.CreateApplication(s.ctx) + + test.WithUser(s.ctx, 5) + s.withFormData("name=two") + s.a.CreateApplication(s.ctx) + + test.WithUser(s.ctx, 5) + s.withFormData("name=three") + s.a.CreateApplication(s.ctx) + + apps, err := s.db.GetApplicationsByUser(5) + require.NoError(s.T(), err) + require.Len(s.T(), apps, 3) + assert.Equal(s.T(), apps[0].Name, "one") + assert.Equal(s.T(), apps[0].SortKey, "a0") + assert.Equal(s.T(), apps[1].Name, "two") + assert.Equal(s.T(), apps[1].SortKey, "a1") + assert.Equal(s.T(), apps[2].Name, "three") + assert.Equal(s.T(), apps[2].SortKey, "a2") + + s.withFormData("name=one&description=&sortKey=a1V") + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(apps[0].ID)}} + s.a.UpdateApplication(s.ctx) + + apps, err = s.db.GetApplicationsByUser(5) + require.NoError(s.T(), err) + require.Len(s.T(), apps, 3) + assert.Equal(s.T(), apps[0].Name, "two") + assert.Equal(s.T(), apps[0].SortKey, "a1") + assert.Equal(s.T(), apps[1].Name, "one") + assert.Equal(s.T(), apps[1].SortKey, "a1V") + assert.Equal(s.T(), apps[2].Name, "three") + assert.Equal(s.T(), apps[2].SortKey, "a2") +} + func (s *ApplicationSuite) Test_GetApplications() { userBuilder := s.db.User(5) first := userBuilder.NewAppWithToken(1, "perfper") @@ -481,6 +529,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationNameAndDescription_expectSucces UserID: 5, Name: "new_name", Description: "new_description_text", + SortKey: "a0", } assert.Equal(s.T(), 200, s.recorder.Code) @@ -503,6 +552,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationName_expectSuccess() { UserID: 5, Name: "new_name", Description: "", + SortKey: "a0", } assert.Equal(s.T(), 200, s.recorder.Code) @@ -526,6 +576,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess() Name: "name", Description: "", DefaultPriority: 4, + SortKey: "a0", } assert.Equal(s.T(), 200, s.recorder.Code) @@ -534,9 +585,10 @@ func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess() } } -func (s *ApplicationSuite) Test_UpdateApplication_preservesImage() { +func (s *ApplicationSuite) Test_UpdateApplication_preservesImageAndSortKey() { app := s.db.User(5).NewAppWithToken(2, "app-2") app.Image = "existing.png" + app.SortKey = "a5" assert.Nil(s.T(), s.db.UpdateApplication(app)) test.WithUser(s.ctx, 5) @@ -548,6 +600,7 @@ func (s *ApplicationSuite) Test_UpdateApplication_preservesImage() { assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) { assert.Equal(s.T(), "existing.png", app.Image) + assert.Equal(s.T(), "a5", app.SortKey) } } diff --git a/database/application.go b/database/application.go index 7f5ace5..eb4ad09 100644 --- a/database/application.go +++ b/database/application.go @@ -1,8 +1,10 @@ package database import ( + "database/sql" "time" + "github.com/gotify/server/v2/fracdex" "github.com/gotify/server/v2/model" "gorm.io/gorm" ) @@ -35,7 +37,21 @@ func (d *GormDatabase) GetApplicationByID(id uint) (*model.Application, error) { // CreateApplication creates an application. func (d *GormDatabase) CreateApplication(application *model.Application) error { - return d.DB.Create(application).Error + return d.DB.Transaction(func(tx *gorm.DB) error { + if application.SortKey == "" { + sortKey := "" + err := tx.Model(&model.Application{}).Select("sort_key").Where("user_id = ?", application.UserID).Order("sort_key DESC").Limit(1).Find(&sortKey).Error + if err != nil && err != gorm.ErrRecordNotFound { + return err + } + application.SortKey, err = fracdex.KeyBetween(sortKey, "") + if err != nil { + return err + } + } + + return tx.Create(application).Error + }, &sql.TxOptions{Isolation: sql.LevelSerializable}) } // DeleteApplicationByID deletes an application by its id. @@ -47,7 +63,7 @@ func (d *GormDatabase) DeleteApplicationByID(id uint) error { // GetApplicationsByUser returns all applications from a user. func (d *GormDatabase) GetApplicationsByUser(userID uint) ([]*model.Application, error) { var apps []*model.Application - err := d.DB.Where("user_id = ?", userID).Order("id ASC").Find(&apps).Error + err := d.DB.Where("user_id = ?", userID).Order("sort_key, id ASC").Find(&apps).Error if err == gorm.ErrRecordNotFound { err = nil } diff --git a/database/database.go b/database/database.go index fd4bd2f..cd45315 100644 --- a/database/database.go +++ b/database/database.go @@ -1,14 +1,18 @@ package database import ( + "database/sql" "errors" + "fmt" "log" + "math" "os" "path/filepath" "time" "github.com/gotify/server/v2/auth/password" "github.com/gotify/server/v2/mode" + "github.com/gotify/server/v2/fracdex" "github.com/gotify/server/v2/model" "github.com/mattn/go-isatty" "gorm.io/driver/mysql" @@ -91,9 +95,46 @@ func New(dialect, connection, defaultUser, defaultPass string, strength int, cre db.Create(&model.User{Name: defaultUser, Pass: password.CreatePassword(defaultPass, strength), Admin: true}) } + if err := db.Transaction(fillMissingSortKeys, &sql.TxOptions{Isolation: sql.LevelSerializable}); err != nil { + return nil, err + } + return &GormDatabase{DB: db}, nil } +func fillMissingSortKeys(db *gorm.DB) error { + missingSort := int64(0) + if err := db.Model(new(model.Application)).Where("sort_key IS NULL OR sort_key = ''").Count(&missingSort).Error; err != nil { + return err + } + + if missingSort == 0 { + return nil + } + + var apps []*model.Application + if err := db.Order("user_id, sort_key, id ASC").Find(&apps).Error; err != nil && err != gorm.ErrRecordNotFound { + return err + } + fmt.Println("Migrating", len(apps), "application sort keys for") + + sortKey := "" + currentUser := uint(math.MaxUint) + var err error + for _, app := range apps { + if currentUser != app.UserID { + sortKey = "" + currentUser = app.UserID + } + sortKey, err = fracdex.KeyBetween(sortKey, "") + if err != nil { + return err + } + app.SortKey = sortKey + } + return db.Save(apps).Error +} + func createDirectoryIfSqlite(dialect, connection string) { if dialect == "sqlite3" { if _, err := os.Stat(filepath.Dir(connection)); os.IsNotExist(err) { diff --git a/database/database_test.go b/database/database_test.go index 3cde6df..cc962e1 100644 --- a/database/database_test.go +++ b/database/database_test.go @@ -2,12 +2,16 @@ package database import ( "errors" + "fmt" "os" "testing" + "time" + "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "gorm.io/gorm" ) func TestDatabaseSuite(t *testing.T) { @@ -69,3 +73,42 @@ func TestPanicsOnMkdirError(t *testing.T) { New("sqlite3", tmpDir.Path("somepath/test.db"), "defaultUser", "defaultPass", 5, true) }) } + +func TestMigrateSortKey(t *testing.T) { + db, err := New("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared", fmt.Sprint(time.Now().UnixNano())), "admin", "pw", 5, true) + assert.Nil(t, err) + assert.NotNil(t, db) + + err = db.CreateApplication(&model.Application{Name: "one", Token: "one", UserID: 1}) + assert.NoError(t, err) + err = db.CreateApplication(&model.Application{Name: "two", Token: "two", UserID: 1}) + assert.NoError(t, err) + err = db.CreateApplication(&model.Application{Name: "three", Token: "three", UserID: 1}) + assert.NoError(t, err) + err = db.CreateApplication(&model.Application{Name: "one-other", Token: "one-other", UserID: 2}) + assert.NoError(t, err) + + err = db.DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(new(model.Application)).UpdateColumn("sort_key", nil).Error + assert.NoError(t, err) + + err = fillMissingSortKeys(db.DB) + assert.NoError(t, err) + + apps, err := db.GetApplicationsByUser(1) + assert.NoError(t, err) + + assert.Len(t, apps, 3) + assert.Equal(t, apps[0].Name, "one") + assert.Equal(t, apps[0].SortKey, "a0") + assert.Equal(t, apps[1].Name, "two") + assert.Equal(t, apps[1].SortKey, "a1") + assert.Equal(t, apps[2].Name, "three") + assert.Equal(t, apps[2].SortKey, "a2") + + apps, err = db.GetApplicationsByUser(2) + assert.NoError(t, err) + + assert.Len(t, apps, 1) + assert.Equal(t, apps[0].Name, "one-other") + assert.Equal(t, apps[0].SortKey, "a0") +} diff --git a/docs/spec.json b/docs/spec.json index 450450e..1e26969 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -2060,7 +2060,8 @@ "name", "description", "internal", - "image" + "image", + "sortKey" ], "properties": { "defaultPriority": { @@ -2112,6 +2113,12 @@ "x-go-name": "Name", "example": "Backup Server" }, + "sortKey": { + "description": "The sort key of this application. Uses fractional indexing.", + "type": "string", + "x-go-name": "SortKey", + "example": "a1" + }, "token": { "description": "The application token. Can be used as `appToken`. See Authentication.", "type": "string", @@ -2148,6 +2155,12 @@ "type": "string", "x-go-name": "Name", "example": "Backup Server" + }, + "sortKey": { + "description": "The sortKey for the application. Uses fractional indexing.", + "type": "string", + "x-go-name": "SortKey", + "example": "a1" } }, "x-go-package": "github.com/gotify/server/v2/api" diff --git a/model/application.go b/model/application.go index b08a56c..4a86cd3 100644 --- a/model/application.go +++ b/model/application.go @@ -54,4 +54,9 @@ type Application struct { // read only: true // example: 2019-01-01T00:00:00Z LastUsed *time.Time `json:"lastUsed"` + // The sort key of this application. Uses fractional indexing. + // + // required: true + // example: a1 + SortKey string `form:"sortKey" query:"sortKey" json:"sortKey"` } diff --git a/test/testdb/database_test.go b/test/testdb/database_test.go index 3dd4b33..bd1eb5f 100644 --- a/test/testdb/database_test.go +++ b/test/testdb/database_test.go @@ -100,27 +100,31 @@ func (s *DatabaseSuite) Test_Apps() { s.db.User(2).InternalApp(5) - newAppExpected := &model.Application{ID: 2, Token: "asdf", UserID: 1} - newInternalAppExpected := &model.Application{ID: 3, Token: "qwer", UserID: 1, Internal: true} + newAppExpected := &model.Application{ID: 2, Token: "asdf", UserID: 1, SortKey: "a1"} + newInternalAppExpected := &model.Application{ID: 3, Token: "qwer", UserID: 1, Internal: true, SortKey: "a2"} assert.Equal(s.T(), newAppExpected, newAppActual) assert.Equal(s.T(), newInternalAppExpected, newInternalAppActual) - userOneExpected := []*model.Application{{ID: 1, Token: "app1", UserID: 1}, {ID: 2, Token: "asdf", UserID: 1}, {ID: 3, Token: "qwer", UserID: 1, Internal: true}} + userOneExpected := []*model.Application{ + {ID: 1, Token: "app1", UserID: 1, SortKey: "a0"}, + {ID: 2, Token: "asdf", UserID: 1, SortKey: "a1"}, + {ID: 3, Token: "qwer", UserID: 1, Internal: true, SortKey: "a2"}, + } if app, err := s.db.GetApplicationsByUser(1); assert.NoError(s.T(), err) { assert.Equal(s.T(), userOneExpected, app) } - userTwoExpected := []*model.Application{{ID: 5, Token: "app5", UserID: 2, Internal: true}} + userTwoExpected := []*model.Application{{ID: 5, Token: "app5", UserID: 2, Internal: true, SortKey: "a0"}} if app, err := s.db.GetApplicationsByUser(2); assert.NoError(s.T(), err) { assert.Equal(s.T(), userTwoExpected, app) } newAppWithName := userBuilder.NewAppWithTokenAndName(7, "test-token", "app name") - newAppWithNameExpected := &model.Application{ID: 7, Token: "test-token", UserID: 1, Name: "app name"} + newAppWithNameExpected := &model.Application{ID: 7, Token: "test-token", UserID: 1, Name: "app name", SortKey: "a3"} assert.Equal(s.T(), newAppWithNameExpected, newAppWithName) newInternalAppWithName := userBuilder.NewInternalAppWithTokenAndName(8, "test-tokeni", "app name") - newInternalAppWithNameExpected := &model.Application{ID: 8, Token: "test-tokeni", UserID: 1, Name: "app name", Internal: true} + newInternalAppWithNameExpected := &model.Application{ID: 8, Token: "test-tokeni", UserID: 1, Name: "app name", Internal: true, SortKey: "a4"} assert.Equal(s.T(), newInternalAppWithNameExpected, newInternalAppWithName) userBuilder.AppWithTokenAndName(9, "test-token-2", "app name")