Skip to content

Commit 1818c5d

Browse files
authored
Merge pull request #909 from gotify/sort_key
Allow sorting applications
2 parents cc2fae5 + a1f83c5 commit 1818c5d

File tree

16 files changed

+943
-114
lines changed

16 files changed

+943
-114
lines changed

api/application.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/gotify/server/v2/auth"
1313
"github.com/gotify/server/v2/model"
1414
"github.com/h2non/filetype"
15+
"gorm.io/gorm"
1516
)
1617

1718
// The ApplicationDatabase interface for encapsulating database access.
@@ -49,6 +50,10 @@ type ApplicationParams struct {
4950
//
5051
// example: 5
5152
DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"`
53+
// The sortKey for the application. Uses fractional indexing.
54+
//
55+
// example: a1
56+
SortKey string `form:"sortKey" query:"sortKey" json:"sortKey"`
5257
}
5358

5459
// CreateApplication creates an application and returns the access token.
@@ -91,12 +96,14 @@ func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) {
9196
Name: applicationParams.Name,
9297
Description: applicationParams.Description,
9398
DefaultPriority: applicationParams.DefaultPriority,
99+
SortKey: applicationParams.SortKey,
94100
Token: auth.GenerateNotExistingToken(generateApplicationToken, a.applicationExists),
95101
UserID: auth.GetUserID(ctx),
96102
Internal: false,
97103
}
98104

99-
if success := successOrAbort(ctx, 500, a.DB.CreateApplication(&app)); !success {
105+
if err := a.DB.CreateApplication(&app); err != nil {
106+
handleApplicationError(ctx, err)
100107
return
101108
}
102109
ctx.JSON(200, withResolvedImage(&app))
@@ -252,8 +259,12 @@ func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) {
252259
app.Description = applicationParams.Description
253260
app.Name = applicationParams.Name
254261
app.DefaultPriority = applicationParams.DefaultPriority
262+
if applicationParams.SortKey != "" {
263+
app.SortKey = applicationParams.SortKey
264+
}
255265

256-
if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success {
266+
if err := a.DB.UpdateApplication(app); err != nil {
267+
handleApplicationError(ctx, err)
257268
return
258269
}
259270
ctx.JSON(200, withResolvedImage(app))
@@ -468,3 +479,11 @@ func ValidApplicationImageExt(ext string) bool {
468479
return false
469480
}
470481
}
482+
483+
func handleApplicationError(ctx *gin.Context, err error) {
484+
if errors.Is(err, gorm.ErrDuplicatedKey) {
485+
ctx.AbortWithError(400, errors.New("sort key is not unique"))
486+
} else {
487+
ctx.AbortWithError(500, err)
488+
}
489+
}

api/application_test.go

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"encoding/json"
66
"errors"
7+
"fmt"
78
"io"
89
"mime/multipart"
910
"net/http/httptest"
@@ -17,12 +18,14 @@ import (
1718
"github.com/gotify/server/v2/test"
1819
"github.com/gotify/server/v2/test/testdb"
1920
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
2022
"github.com/stretchr/testify/suite"
2123
)
2224

2325
var (
2426
firstApplicationToken = "Aaaaaaaaaaaaaaa"
2527
secondApplicationToken = "Abbbbbbbbbbbbbb"
28+
thirdApplicationToken = "Acccccccccccccc"
2629
)
2730

2831
func TestApplicationSuite(t *testing.T) {
@@ -45,8 +48,8 @@ var (
4548
func (s *ApplicationSuite) BeforeTest(suiteName, testName string) {
4649
originalGenerateApplicationToken = generateApplicationToken
4750
originalGenerateImageName = generateImageName
48-
generateApplicationToken = test.Tokens(firstApplicationToken, secondApplicationToken)
49-
generateImageName = test.Tokens(firstApplicationToken[1:], secondApplicationToken[1:])
51+
generateApplicationToken = test.Tokens(firstApplicationToken, secondApplicationToken, thirdApplicationToken)
52+
generateImageName = test.Tokens(firstApplicationToken[1:], secondApplicationToken[1:], thirdApplicationToken[1:])
5053
mode.Set(mode.TestDev)
5154
s.recorder = httptest.NewRecorder()
5255
s.db = testdb.NewDB(s.T())
@@ -65,7 +68,7 @@ func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() {
6568
s.db.User(5)
6669

6770
test.WithUser(s.ctx, 5)
68-
s.withFormData("name=custom_name&description=description_text")
71+
s.withFormData("name=custom_name&description=description_text&sortKey=a5")
6972
s.a.CreateApplication(s.ctx)
7073

7174
expected := &model.Application{
@@ -74,6 +77,7 @@ func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() {
7477
UserID: 5,
7578
Name: "custom_name",
7679
Description: "description_text",
80+
SortKey: "a5",
7781
}
7882
assert.Equal(s.T(), 200, s.recorder.Code)
7983
if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) {
@@ -91,8 +95,9 @@ func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation()
9195
Image: "asd",
9296
Internal: true,
9397
LastUsed: nil,
98+
SortKey: "a1",
9499
}
95-
test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null}`)
100+
test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null, "sortKey":"a1"}`)
96101
}
97102

98103
func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() {
@@ -119,6 +124,7 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar
119124
Internal: true,
120125
Token: "token",
121126
Image: "adfdf",
127+
SortKey: "a5",
122128
})
123129

124130
s.a.CreateApplication(s.ctx)
@@ -131,6 +137,7 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar
131137
Description: "description",
132138
Internal: false,
133139
Image: "static/defaultapp.png",
140+
SortKey: "a5",
134141
})
135142

136143
assert.Equal(s.T(), 200, s.recorder.Code)
@@ -158,7 +165,7 @@ func (s *ApplicationSuite) Test_CreateApplication_onlyRequiredParameters() {
158165
s.withFormData("name=custom_name")
159166
s.a.CreateApplication(s.ctx)
160167

161-
expected := &model.Application{ID: 1, Token: firstApplicationToken, Name: "custom_name", UserID: 5}
168+
expected := &model.Application{ID: 1, Token: firstApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0"}
162169
assert.Equal(s.T(), 200, s.recorder.Code)
163170
if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) {
164171
assert.Contains(s.T(), app, expected)
@@ -174,11 +181,12 @@ func (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWithID() {
174181
s.a.CreateApplication(s.ctx)
175182

176183
expected := &model.Application{
177-
ID: 1,
178-
Token: firstApplicationToken,
179-
Name: "custom_name",
180-
Image: "static/defaultapp.png",
181-
UserID: 5,
184+
ID: 1,
185+
Token: firstApplicationToken,
186+
Name: "custom_name",
187+
Image: "static/defaultapp.png",
188+
UserID: 5,
189+
SortKey: "a0",
182190
}
183191
assert.Equal(s.T(), 200, s.recorder.Code)
184192
test.BodyEquals(s.T(), expected, s.recorder)
@@ -193,13 +201,53 @@ func (s *ApplicationSuite) Test_CreateApplication_withExistingToken() {
193201

194202
s.a.CreateApplication(s.ctx)
195203

196-
expected := &model.Application{ID: 2, Token: secondApplicationToken, Name: "custom_name", UserID: 5}
204+
expected := &model.Application{ID: 2, Token: secondApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0"}
197205
assert.Equal(s.T(), 200, s.recorder.Code)
198206
if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) {
199207
assert.Contains(s.T(), app, expected)
200208
}
201209
}
202210

211+
func (s *ApplicationSuite) Test_Sorting() {
212+
s.db.User(5)
213+
214+
test.WithUser(s.ctx, 5)
215+
s.withFormData("name=one")
216+
s.a.CreateApplication(s.ctx)
217+
218+
test.WithUser(s.ctx, 5)
219+
s.withFormData("name=two")
220+
s.a.CreateApplication(s.ctx)
221+
222+
test.WithUser(s.ctx, 5)
223+
s.withFormData("name=three")
224+
s.a.CreateApplication(s.ctx)
225+
226+
apps, err := s.db.GetApplicationsByUser(5)
227+
require.NoError(s.T(), err)
228+
require.Len(s.T(), apps, 3)
229+
assert.Equal(s.T(), apps[0].Name, "one")
230+
assert.Equal(s.T(), apps[0].SortKey, "a0")
231+
assert.Equal(s.T(), apps[1].Name, "two")
232+
assert.Equal(s.T(), apps[1].SortKey, "a1")
233+
assert.Equal(s.T(), apps[2].Name, "three")
234+
assert.Equal(s.T(), apps[2].SortKey, "a2")
235+
236+
s.withFormData("name=one&description=&sortKey=a1V")
237+
s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(apps[0].ID)}}
238+
s.a.UpdateApplication(s.ctx)
239+
240+
apps, err = s.db.GetApplicationsByUser(5)
241+
require.NoError(s.T(), err)
242+
require.Len(s.T(), apps, 3)
243+
assert.Equal(s.T(), apps[0].Name, "two")
244+
assert.Equal(s.T(), apps[0].SortKey, "a1")
245+
assert.Equal(s.T(), apps[1].Name, "one")
246+
assert.Equal(s.T(), apps[1].SortKey, "a1V")
247+
assert.Equal(s.T(), apps[2].Name, "three")
248+
assert.Equal(s.T(), apps[2].SortKey, "a2")
249+
}
250+
203251
func (s *ApplicationSuite) Test_GetApplications() {
204252
userBuilder := s.db.User(5)
205253
first := userBuilder.NewAppWithToken(1, "perfper")
@@ -481,6 +529,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationNameAndDescription_expectSucces
481529
UserID: 5,
482530
Name: "new_name",
483531
Description: "new_description_text",
532+
SortKey: "a0",
484533
}
485534

486535
assert.Equal(s.T(), 200, s.recorder.Code)
@@ -503,6 +552,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationName_expectSuccess() {
503552
UserID: 5,
504553
Name: "new_name",
505554
Description: "",
555+
SortKey: "a0",
506556
}
507557

508558
assert.Equal(s.T(), 200, s.recorder.Code)
@@ -526,6 +576,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess()
526576
Name: "name",
527577
Description: "",
528578
DefaultPriority: 4,
579+
SortKey: "a0",
529580
}
530581

531582
assert.Equal(s.T(), 200, s.recorder.Code)
@@ -534,9 +585,10 @@ func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess()
534585
}
535586
}
536587

537-
func (s *ApplicationSuite) Test_UpdateApplication_preservesImage() {
588+
func (s *ApplicationSuite) Test_UpdateApplication_preservesImageAndSortKey() {
538589
app := s.db.User(5).NewAppWithToken(2, "app-2")
539590
app.Image = "existing.png"
591+
app.SortKey = "a5"
540592
assert.Nil(s.T(), s.db.UpdateApplication(app))
541593

542594
test.WithUser(s.ctx, 5)
@@ -548,6 +600,7 @@ func (s *ApplicationSuite) Test_UpdateApplication_preservesImage() {
548600
assert.Equal(s.T(), 200, s.recorder.Code)
549601
if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) {
550602
assert.Equal(s.T(), "existing.png", app.Image)
603+
assert.Equal(s.T(), "a5", app.SortKey)
551604
}
552605
}
553606

@@ -594,6 +647,21 @@ func (s *ApplicationSuite) Test_UpdateApplication_WithoutPermission_expectNotFou
594647
assert.Equal(s.T(), 404, s.recorder.Code)
595648
}
596649

650+
func (s *ApplicationSuite) Test_UpdateApplication_duplicateSortKey() {
651+
user := s.db.User(5)
652+
user.App(1) // sortKey=a0
653+
user.App(2) // sortKey=a1
654+
655+
s.withFormData("name=new_name&sortKey=a0")
656+
test.WithUser(s.ctx, 5)
657+
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
658+
659+
s.a.UpdateApplication(s.ctx)
660+
661+
assert.EqualError(s.T(), s.ctx.Errors[0].Err, "sort key is not unique")
662+
assert.Equal(s.T(), 400, s.recorder.Code)
663+
}
664+
597665
func (s *ApplicationSuite) withFormData(formData string) {
598666
s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData))
599667
s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

database/application.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package database
22

33
import (
4+
"database/sql"
45
"time"
56

7+
"github.com/gotify/server/v2/fracdex"
68
"github.com/gotify/server/v2/model"
79
"gorm.io/gorm"
810
)
@@ -35,7 +37,21 @@ func (d *GormDatabase) GetApplicationByID(id uint) (*model.Application, error) {
3537

3638
// CreateApplication creates an application.
3739
func (d *GormDatabase) CreateApplication(application *model.Application) error {
38-
return d.DB.Create(application).Error
40+
return d.DB.Transaction(func(tx *gorm.DB) error {
41+
if application.SortKey == "" {
42+
sortKey := ""
43+
err := tx.Model(&model.Application{}).Select("sort_key").Where("user_id = ?", application.UserID).Order("sort_key DESC").Limit(1).Find(&sortKey).Error
44+
if err != nil && err != gorm.ErrRecordNotFound {
45+
return err
46+
}
47+
application.SortKey, err = fracdex.KeyBetween(sortKey, "")
48+
if err != nil {
49+
return err
50+
}
51+
}
52+
53+
return tx.Create(application).Error
54+
}, &sql.TxOptions{Isolation: sql.LevelSerializable})
3955
}
4056

4157
// DeleteApplicationByID deletes an application by its id.
@@ -47,7 +63,7 @@ func (d *GormDatabase) DeleteApplicationByID(id uint) error {
4763
// GetApplicationsByUser returns all applications from a user.
4864
func (d *GormDatabase) GetApplicationsByUser(userID uint) ([]*model.Application, error) {
4965
var apps []*model.Application
50-
err := d.DB.Where("user_id = ?", userID).Order("id ASC").Find(&apps).Error
66+
err := d.DB.Where("user_id = ?", userID).Order("sort_key, id ASC").Find(&apps).Error
5167
if err == gorm.ErrRecordNotFound {
5268
err = nil
5369
}

0 commit comments

Comments
 (0)