Ensure case insensitive search on postgres (#927)

Reviewed-on: https://kolaente.dev/vikunja/api/pulls/927
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2021-08-01 21:40:25 +00:00
parent 9c2a59582a
commit 4c5f457313
23 changed files with 308 additions and 108 deletions

35
pkg/db/helpers.go Normal file
View file

@ -0,0 +1,35 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package db
import (
"xorm.io/builder"
"xorm.io/xorm/schemas"
)
// ILIKE returns an ILIKE query on postgres and a LIKE query on all other platforms.
// Postgres' is case sensitive by default.
// To work around this, we're using ILIKE as opposed to normal LIKE statements.
// ILIKE is preferred over LOWER(text) LIKE for performance reasons.
// See https://stackoverflow.com/q/7005302/10924593
func ILIKE(column, search string) builder.Cond {
if Type() == schemas.POSTGRES {
return builder.Expr(column+" ILIKE ?", "%"+search+"%")
}
return &builder.Like{column, "%" + search + "%"}
}

View file

@ -21,9 +21,12 @@ import (
"strings"
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/builder"
"xorm.io/xorm"
)
@ -200,7 +203,7 @@ func getLabelsByTaskIDs(s *xorm.Session, opts *LabelByTaskIDsOptions) (ls []*lab
if len(ids) > 0 {
cond = builder.And(cond, builder.In("labels.id", ids))
} else {
cond = builder.And(cond, &builder.Like{"labels.title", "%" + opts.Search + "%"})
cond = builder.And(cond, db.ILIKE("labels.title", opts.Search))
}
limit, start := getLimitFromPageIndex(opts.Page, opts.PerPage)

View file

@ -30,6 +30,24 @@ import (
)
func TestLabelTask_ReadAll(t *testing.T) {
label := Label{
ID: 4,
Title: "Label #4 - visible via other task",
Created: testCreatedTime,
Updated: testUpdatedTime,
CreatedByID: 2,
CreatedBy: &user.User{
ID: 2,
Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
}
type fields struct {
ID int64
TaskID int64
@ -62,23 +80,7 @@ func TestLabelTask_ReadAll(t *testing.T) {
wantLabels: []*labelWithTaskID{
{
TaskID: 1,
Label: Label{
ID: 4,
Title: "Label #4 - visible via other task",
Created: testCreatedTime,
Updated: testUpdatedTime,
CreatedByID: 2,
CreatedBy: &user.User{
ID: 2,
Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
},
Label: label,
},
},
},
@ -104,6 +106,22 @@ func TestLabelTask_ReadAll(t *testing.T) {
wantErr: true,
errType: IsErrTaskDoesNotExist,
},
{
name: "search",
fields: fields{
TaskID: 1,
},
args: args{
a: &user.User{ID: 1},
search: "VISIBLE",
},
wantLabels: []*labelWithTaskID{
{
TaskID: 1,
Label: label,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -20,12 +20,15 @@ import (
"errors"
"time"
"golang.org/x/crypto/bcrypt"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/web"
"github.com/golang-jwt/jwt"
"golang.org/x/crypto/bcrypt"
"xorm.io/builder"
"xorm.io/xorm"
)
@ -206,7 +209,14 @@ func (share *LinkSharing) ReadAll(s *xorm.Session, a web.Auth, search string, pa
var shares []*LinkSharing
query := s.
Where("list_id = ? AND hash LIKE ?", share.ListID, "%"+search+"%")
Where(builder.And(
builder.Eq{"list_id": share.ListID},
builder.Or(
db.ILIKE("hash", search),
db.ILIKE("name", search),
),
))
if limit > 0 {
query = query.Limit(limit, start)
}

View file

@ -103,6 +103,21 @@ func TestLinkSharing_ReadAll(t *testing.T) {
assert.Empty(t, sharing.Password)
}
})
t.Run("search", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
share := &LinkSharing{
ListID: 1,
}
all, _, _, err := share.ReadAll(s, doer, "wITHPASS", 1, -1)
shares := all.([]*LinkSharing)
assert.NoError(t, err)
assert.Len(t, shares, 1)
assert.Equal(t, int64(4), shares[0].ID)
})
}
func TestLinkSharing_ReadOne(t *testing.T) {

View file

@ -21,6 +21,8 @@ import (
"strings"
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/files"
@ -403,10 +405,9 @@ func getRawListsForUser(s *xorm.Session, opts *listOptions) (lists []*List, resu
}
}
filterCond = db.ILIKE("l.title", opts.search)
if len(ids) > 0 {
filterCond = builder.In("l.id", ids)
} else {
filterCond = &builder.Like{"l.title", "%" + opts.search + "%"}
}
// Gets all Lists where the user is either owner or in a team which has access to the list

View file

@ -19,6 +19,8 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/web"
@ -198,7 +200,7 @@ func (tl *TeamList) ReadAll(s *xorm.Session, a web.Auth, search string, page int
Table("teams").
Join("INNER", "team_lists", "team_id = teams.id").
Where("team_lists.list_id = ?", tl.ListID).
Where("teams.name LIKE ?", "%"+search+"%")
Where(db.ILIKE("teams.name", search))
if limit > 0 {
query = query.Limit(limit, start)
}

View file

@ -81,6 +81,20 @@ func TestTeamList_ReadAll(t *testing.T) {
assert.True(t, IsErrNeedToHaveListReadAccess(err))
_ = s.Close()
})
t.Run("search", func(t *testing.T) {
tl := TeamList{
ListID: 19,
}
db.LoadAndAssertFixtures(t)
s := db.NewSession()
teams, _, _, err := tl.ReadAll(s, u, "TEAM9", 1, 50)
assert.NoError(t, err)
assert.Equal(t, reflect.TypeOf(teams).Kind(), reflect.Slice)
ts := teams.([]*TeamWithRight)
assert.Len(t, ts, 1)
assert.Equal(t, int64(9), ts[0].ID)
_ = s.Close()
})
}
func TestTeamList_Create(t *testing.T) {

View file

@ -217,6 +217,19 @@ func TestList_ReadAll(t *testing.T) {
assert.True(t, user.IsErrUserDoesNotExist(err))
_ = s.Close()
})
t.Run("search", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
u := &user.User{ID: 1}
list := List{}
lists3, _, _, err := list.ReadAll(s, u, "TEST10", 1, 50)
assert.NoError(t, err)
ls := lists3.([]*List)
assert.Equal(t, 1, len(ls))
assert.Equal(t, int64(10), ls[0].ID)
_ = s.Close()
})
}
func TestList_ReadOne(t *testing.T) {

View file

@ -19,6 +19,8 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user"
@ -204,7 +206,7 @@ func (lu *ListUser) ReadAll(s *xorm.Session, a web.Auth, search string, page int
query := s.
Join("INNER", "users_lists", "user_id = users.id").
Where("users_lists.list_id = ?", lu.ListID).
Where("users.username LIKE ?", "%"+search+"%")
Where(db.ILIKE("users.username", search))
if limit > 0 {
query = query.Limit(limit, start)
}

View file

@ -143,6 +143,33 @@ func TestListUser_Create(t *testing.T) {
}
func TestListUser_ReadAll(t *testing.T) {
user1Read := &UserWithRight{
User: user.User{
ID: 1,
Username: "user1",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
Right: RightRead,
}
user2Read := &UserWithRight{
User: user.User{
ID: 2,
Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
Right: RightRead,
}
type fields struct {
ID int64
UserID int64
@ -175,32 +202,8 @@ func TestListUser_ReadAll(t *testing.T) {
a: &user.User{ID: 3},
},
want: []*UserWithRight{
{
User: user.User{
ID: 1,
Username: "user1",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
Right: RightRead,
},
{
User: user.User{
ID: 2,
Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
Right: RightRead,
},
user1Read,
user2Read,
},
},
{
@ -214,6 +217,19 @@ func TestListUser_ReadAll(t *testing.T) {
wantErr: true,
errType: IsErrNeedToHaveListReadAccess,
},
{
name: "Search",
fields: fields{
ListID: 3,
},
args: args{
a: &user.User{ID: 3},
search: "USER2",
},
want: []*UserWithRight{
user2Read,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -22,10 +22,12 @@ import (
"strings"
"time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/builder"
"xorm.io/xorm"
@ -200,7 +202,7 @@ func makeNamespaceSlice(namespaces map[int64]*NamespaceWithLists, userMap map[in
}
func getNamespaceFilterCond(search string) (filterCond builder.Cond) {
filterCond = &builder.Like{"namespaces.title", "%" + search + "%"}
filterCond = db.ILIKE("namespaces.title", search)
if search == "" {
return

View file

@ -19,9 +19,11 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/web"
"xorm.io/xorm"
)
@ -178,14 +180,12 @@ func (tn *TeamNamespace) ReadAll(s *xorm.Session, a web.Auth, search string, pag
// Get the teams
all := []*TeamWithRight{}
limit, start := getLimitFromPageIndex(page, perPage)
query := s.
Table("teams").
Join("INNER", "team_namespaces", "team_id = teams.id").
Where("team_namespaces.namespace_id = ?", tn.NamespaceID).
Where("teams.name LIKE ?", "%"+search+"%")
Where(db.ILIKE("teams.name", search))
if limit > 0 {
query = query.Limit(limit, start)
}

View file

@ -66,6 +66,21 @@ func TestTeamNamespace_ReadAll(t *testing.T) {
assert.True(t, IsErrNeedToHaveNamespaceReadAccess(err))
_ = s.Close()
})
t.Run("search", func(t *testing.T) {
tn := TeamNamespace{
NamespaceID: 3,
}
db.LoadAndAssertFixtures(t)
s := db.NewSession()
teams, _, _, err := tn.ReadAll(s, u, "READ_only_on_list6", 1, 50)
assert.NoError(t, err)
assert.Equal(t, reflect.TypeOf(teams).Kind(), reflect.Slice)
ts := teams.([]*TeamWithRight)
assert.Len(t, ts, 1)
assert.Equal(t, int64(2), ts[0].ID)
_ = s.Close()
})
}
func TestTeamNamespace_Create(t *testing.T) {

View file

@ -356,4 +356,17 @@ func TestNamespace_ReadAll(t *testing.T) {
assert.NoError(t, err)
assert.Nil(t, nn)
})
t.Run("search", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
n := &Namespace{}
nn, _, _, err := n.ReadAll(s, user6, "NamespACE7", 1, -1)
assert.NoError(t, err)
namespaces := nn.([]*NamespaceWithLists)
assert.NotNil(t, namespaces)
assert.Len(t, namespaces, 2)
assert.Equal(t, int64(7), namespaces[1].ID)
})
}

View file

@ -19,10 +19,12 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
user2 "code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/xorm"
)
@ -185,13 +187,11 @@ func (nu *NamespaceUser) ReadAll(s *xorm.Session, a web.Auth, search string, pag
// Get all users
all := []*UserWithRight{}
limit, start := getLimitFromPageIndex(page, perPage)
query := s.
Join("INNER", "users_namespaces", "user_id = users.id").
Where("users_namespaces.namespace_id = ?", nu.NamespaceID).
Where("users.username LIKE ?", "%"+search+"%")
Where(db.ILIKE("users.username", search))
if limit > 0 {
query = query.Limit(limit, start)
}

View file

@ -142,6 +142,33 @@ func TestNamespaceUser_Create(t *testing.T) {
}
func TestNamespaceUser_ReadAll(t *testing.T) {
user1 := &UserWithRight{
User: user.User{
ID: 1,
Username: "user1",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
Right: RightRead,
}
user2 := &UserWithRight{
User: user.User{
ID: 2,
Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
Right: RightRead,
}
type fields struct {
ID int64
UserID int64
@ -174,32 +201,8 @@ func TestNamespaceUser_ReadAll(t *testing.T) {
a: &user.User{ID: 3},
},
want: []*UserWithRight{
{
User: user.User{
ID: 1,
Username: "user1",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
Right: RightRead,
},
{
User: user.User{
ID: 2,
Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
Right: RightRead,
},
user1,
user2,
},
},
{
@ -213,6 +216,19 @@ func TestNamespaceUser_ReadAll(t *testing.T) {
wantErr: true,
errType: IsErrNeedToHaveNamespaceReadAccess,
},
{
name: "Search",
fields: fields{
NamespaceID: 3,
},
args: args{
a: &user.User{ID: 3},
search: "usER2",
},
want: []*UserWithRight{
user2,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -19,6 +19,10 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/db"
"xorm.io/builder"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user"
@ -267,12 +271,14 @@ func (la *TaskAssginee) ReadAll(s *xorm.Session, a web.Auth, search string, page
return nil, 0, 0, ErrGenericForbidden{}
}
limit, start := getLimitFromPageIndex(page, perPage)
var taskAssignees []*user.User
query := s.Table("task_assignees").
Select("users.*").
Join("INNER", "users", "task_assignees.user_id = users.id").
Where("task_id = ? AND users.username LIKE ?", la.TaskID, "%"+search+"%")
Where(builder.And(
builder.Eq{"task_id": la.TaskID},
db.ILIKE("users.username", search),
))
if limit > 0 {
query = query.Limit(limit, start)
}

View file

@ -19,12 +19,14 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"xorm.io/xorm"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/builder"
"xorm.io/xorm"
)
// TaskComment represents a task comment
@ -214,10 +216,12 @@ func (tc *TaskComment) ReadAll(s *xorm.Session, auth web.Auth, search string, pa
}
limit, start := getLimitFromPageIndex(page, perPage)
comments := []*TaskComment{}
query := s.
Where("task_id = ? AND comment like ?", tc.TaskID, "%"+search+"%").
Where(builder.And(
builder.Eq{"task_id": tc.TaskID},
db.ILIKE("comment", search),
)).
Join("LEFT", "users", "users.id = task_comments.author_id")
if limit > 0 {
query = query.Limit(limit, start)

View file

@ -233,4 +233,16 @@ func TestTaskComment_ReadAll(t *testing.T) {
}
assert.True(t, foundComment)
})
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tc := &TaskComment{TaskID: 35}
u := &user.User{ID: 1}
result, _, _, err := tc.ReadAll(s, u, "COMMENT 15", 0, -1)
resultComment := result.([]*TaskComment)
assert.NoError(t, err)
assert.Equal(t, int64(15), resultComment[0].ID)
})
}

View file

@ -369,16 +369,7 @@ func getRawTasksForLists(s *xorm.Session, lists []*List, a web.Auth, opts *taskO
var where builder.Cond
if opts.search != "" {
// Postgres' is case sensitive by default.
// To work around this, we're using ILIKE as opposed to normal LIKE statements.
// ILIKE is preferred over LOWER(text) LIKE for performance reasons.
// See https://stackoverflow.com/q/7005302/10924593
// Seems okay to use that now, we may need to find a better solution overall in the future.
if config.DatabaseType.GetString() == "postgres" {
where = builder.Expr("title ILIKE ?", "%"+opts.search+"%")
} else {
where = &builder.Like{"title", "%" + opts.search + "%"}
}
where = db.ILIKE("title", opts.search)
searchIndex := getTaskIndexFromSearchString(opts.search)
if searchIndex > 0 {

View file

@ -19,13 +19,14 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"xorm.io/xorm"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/builder"
"xorm.io/xorm"
)
// Team holds a team object
@ -210,13 +211,12 @@ func (t *Team) ReadAll(s *xorm.Session, a web.Auth, search string, page int, per
}
limit, start := getLimitFromPageIndex(page, perPage)
all := []*Team{}
query := s.Select("teams.*").
Table("teams").
Join("INNER", "team_members", "team_members.team_id = teams.id").
Where("team_members.user_id = ?", a.GetID()).
Where("teams.name LIKE ?", "%"+search+"%")
Where(db.ILIKE("teams.name", search))
if limit > 0 {
query = query.Limit(limit, start)
}

View file

@ -112,6 +112,18 @@ func TestTeam_ReadAll(t *testing.T) {
ts := reflect.ValueOf(teams)
assert.Equal(t, 8, ts.Len())
})
t.Run("search", func(t *testing.T) {
s := db.NewSession()
defer s.Close()
team := &Team{}
teams, _, _, err := team.ReadAll(s, doer, "READ_only_on_list6", 1, 50)
assert.NoError(t, err)
assert.Equal(t, reflect.TypeOf(teams).Kind(), reflect.Slice)
ts := teams.([]*Team)
assert.Len(t, ts, 1)
assert.Equal(t, int64(2), ts[0].ID)
})
}
func TestTeam_Update(t *testing.T) {