Search: Improve album, albums, lens, and camera filters #1994 #2079

Camera and lens can now also be searched by name. Escaping and parsing
of albums has been improved so that albums whose names start with and/or
contain numbers will be found.
This commit is contained in:
Michael Mayer 2022-03-24 18:30:59 +01:00
parent 57dd9015e2
commit 9d110e8b80
20 changed files with 422 additions and 71 deletions

View file

@ -52,6 +52,7 @@ func SearchPhotos(router *gin.RouterGroup) {
}
f.UID = ""
f.Albums = ""
f.Public = true
f.Private = false
f.Hidden = false

View file

@ -76,6 +76,39 @@ var PhotoAlbumFixtures = PhotoAlbumMap{
Photo: PhotoFixtures.Pointer("Photo03"),
Album: AlbumFixtures.Pointer("berlin-2019"),
},
"6": {
PhotoUID: "pt9jtdre2lvl0yh0",
AlbumUID: "at9lxuqxpogaaba8",
Hidden: false,
Missing: false,
Order: 0,
CreatedAt: time.Date(2020, 2, 6, 2, 6, 51, 0, time.UTC),
UpdatedAt: time.Date(2020, 4, 28, 14, 6, 0, 0, time.UTC),
Photo: PhotoFixtures.Pointer("Photo03"),
Album: AlbumFixtures.Pointer("berlin-2019"),
},
"7": {
PhotoUID: "pt9jtdre2lvl0y21",
AlbumUID: "at9lxuqxpogaaba7",
Hidden: false,
Missing: false,
Order: 1,
CreatedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
UpdatedAt: time.Date(2020, 5, 28, 14, 6, 0, 0, time.UTC),
Photo: PhotoFixtures.Pointer("Photo14"),
Album: AlbumFixtures.Pointer("berlin-2019"),
},
"8": {
PhotoUID: "pt9jtdre2lvl0y21",
AlbumUID: "at9lxuqxpogaaba8",
Hidden: false,
Missing: false,
Order: 1,
CreatedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
UpdatedAt: time.Date(2020, 5, 28, 14, 6, 0, 0, time.UTC),
Photo: PhotoFixtures.Pointer("Photo14"),
Album: AlbumFixtures.Pointer("berlin-2019"),
},
}
// CreatePhotoAlbumFixtures inserts known entities into the database for testing.

View file

@ -63,8 +63,8 @@ type SearchPhotos struct {
Faces string `form:"faces"` // Find or exclude faces if detected.
Quality int `form:"quality"`
Review bool `form:"review"`
Camera int `form:"camera"`
Lens int `form:"lens"`
Camera string `form:"camera"`
Lens string `form:"lens"`
Before time.Time `form:"before" time_format:"2006-01-02"`
After time.Time `form:"after" time_format:"2006-01-02"`
Count int `form:"count" binding:"required" serialize:"-"`

View file

@ -104,7 +104,7 @@ func TestParseQueryString(t *testing.T) {
assert.Equal(t, "cat", form.Label)
assert.Equal(t, "fooBar baz", form.Query)
assert.Equal(t, 23, form.Camera)
assert.Equal(t, "23", form.Camera)
assert.Equal(t, time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), form.Before)
assert.Equal(t, false, form.Favorite)
assert.Equal(t, uint(0x61a8), form.Dist)
@ -204,18 +204,27 @@ func TestParseQueryString(t *testing.T) {
assert.Equal(t, "strconv.Atoi: parsing \"cat\": invalid syntax", err.Error())
})
t.Run("query for camera with invalid type", func(t *testing.T) {
t.Run("CameraString", func(t *testing.T) {
form := &SearchPhotos{Query: "camera:cat"}
err := form.ParseQueryString()
if err == nil {
if err != nil {
t.Fatal(err)
}
// log.Debugf("%+v\n", form)
assert.Equal(t, "cat", form.Camera)
})
t.Run("LensString", func(t *testing.T) {
form := &SearchPhotos{Query: "lens:cat"}
assert.Equal(t, "strconv.Atoi: parsing \"cat\": invalid syntax", err.Error())
err := form.ParseQueryString()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "cat", form.Lens)
})
t.Run("query for before with invalid type", func(t *testing.T) {
form := &SearchPhotos{Query: "before:cat"}

View file

@ -32,7 +32,7 @@ func TestAlbumCoverByUID(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "2790/07/27900704_070228_D6D51B6C.jpg", file.FileName)
assert.Equal(t, "1990/04/bridge2.jpg", file.FileName)
})
t.Run("existing uid folder album", func(t *testing.T) {

View file

@ -194,13 +194,12 @@ func Geo(f form.SearchGeo) (results GeoResults, err error) {
}
} else if f.Unsorted && f.Filter == "" {
s = s.Where("photos.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 0)")
} else if f.Albums != "" || f.Album != "" {
if f.Albums == "" {
f.Albums = f.Album
}
} else if txt.NotEmpty(f.Album) {
v := strings.Trim(f.Album, "*%") + "%"
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (a.album_title LIKE ? OR a.album_slug LIKE ?))", v, v)
} else if txt.NotEmpty(f.Albums) {
for _, where := range LikeAnyWord("a.album_title", f.Albums) {
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid WHERE (?))", gorm.Expr(where))
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (?))", gorm.Expr(where))
}
}

View file

@ -361,9 +361,9 @@ func TestGeo(t *testing.T) {
assert.GreaterOrEqual(t, len(photos), 1)
})
t.Run("albums", func(t *testing.T) {
t.Run("Album", func(t *testing.T) {
var f form.SearchGeo
f.Albums = "2030"
f.Album = "Berlin"
photos, err := Geo(f)
@ -371,9 +371,21 @@ func TestGeo(t *testing.T) {
t.Fatal(err)
}
assert.GreaterOrEqual(t, len(photos), 10)
assert.Equal(t, 1, len(photos))
})
t.Run("path or path", func(t *testing.T) {
t.Run("Albums", func(t *testing.T) {
var f form.SearchGeo
f.Albums = "Holiday|Christmas"
photos, err := Geo(f)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, 2, len(photos))
})
t.Run("PathOrPath", func(t *testing.T) {
var f form.SearchGeo
f.Path = "1990/04" + "|" + "2015/11"
@ -591,7 +603,7 @@ func TestGeo(t *testing.T) {
t.Fatal(err)
}
f.Query = "albums:Berlin&Holiday"
f.Query = "albums:\"Berlin&Holiday|Christmas\""
photos2, err2 := Geo(f)

View file

@ -5,10 +5,9 @@ import (
"strings"
"github.com/photoprism/photoprism/pkg/sanitize"
"github.com/gosimple/slug"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/gosimple/slug"
"github.com/jinzhu/inflection"
)
@ -37,7 +36,7 @@ func LikeAny(col, s string, keywords, exact bool) (wheres []string) {
if keywords {
words = txt.UniqueKeywords(k)
} else {
words = txt.UniqueWords(txt.Words(k))
words = txt.UniqueWords(strings.Fields(k))
}
if len(words) == 0 {
@ -46,9 +45,9 @@ func LikeAny(col, s string, keywords, exact bool) (wheres []string) {
for _, w := range words {
if wildcardThreshold > 0 && len(w) >= wildcardThreshold {
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s%%'", col, w))
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s%%'", col, SqlLike(w)))
} else {
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s'", col, w))
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s'", col, SqlLike(w)))
}
if !keywords || !txt.ContainsASCIILetters(w) {
@ -58,7 +57,7 @@ func LikeAny(col, s string, keywords, exact bool) (wheres []string) {
singular := inflection.Singular(w)
if singular != w {
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s'", col, singular))
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s'", col, SqlLike(singular)))
}
}
@ -93,7 +92,7 @@ func LikeAll(col, s string, keywords, exact bool) (wheres []string) {
words = txt.UniqueKeywords(s)
wildcardThreshold = 4
} else {
words = txt.UniqueWords(txt.Words(s))
words = txt.UniqueWords(strings.Fields(s))
wildcardThreshold = 2
}
@ -105,9 +104,9 @@ func LikeAll(col, s string, keywords, exact bool) (wheres []string) {
for _, w := range words {
if wildcardThreshold > 0 && len(w) >= wildcardThreshold {
wheres = append(wheres, fmt.Sprintf("%s LIKE '%s%%'", col, w))
wheres = append(wheres, fmt.Sprintf("%s LIKE '%s%%'", col, SqlLike(w)))
} else {
wheres = append(wheres, fmt.Sprintf("%s LIKE '%s'", col, w))
wheres = append(wheres, fmt.Sprintf("%s LIKE '%s'", col, SqlLike(w)))
}
}
@ -142,9 +141,9 @@ func LikeAllNames(cols Cols, s string) (wheres []string) {
for _, c := range cols {
if strings.Contains(w, txt.Space) {
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s%%'", c, w))
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s%%'", c, SqlLike(w)))
} else {
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%%%s%%'", c, w))
orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%%%s%%'", c, SqlLike(w)))
}
}
}
@ -191,7 +190,7 @@ func AnySlug(col, search, sep string) (where string) {
}
for _, w := range words {
wheres = append(wheres, fmt.Sprintf("%s = '%s'", col, w))
wheres = append(wheres, fmt.Sprintf("%s = '%s'", col, SqlLike(w)))
}
return strings.Join(wheres, " OR ")
@ -233,7 +232,7 @@ func AnyInt(col, numbers, sep string, min, max int) (where string) {
// OrLike returns a where condition and values for finding multiple terms combined with OR.
func OrLike(col, s string) (where string, values []interface{}) {
if col == "" || s == "" {
if txt.IsEmpty(col) || txt.IsEmpty(s) {
return "", []interface{}{}
}

View file

@ -102,7 +102,7 @@ func TestLikeAnyKeyword(t *testing.T) {
}
func TestLikeAnyWord(t *testing.T) {
t.Run("and_or_search", func(t *testing.T) {
t.Run("SearchAndOr", func(t *testing.T) {
if w := LikeAnyWord("k.keyword", "table spoon & usa | img json"); len(w) != 2 {
t.Fatal("two where conditions expected")
} else {
@ -110,7 +110,7 @@ func TestLikeAnyWord(t *testing.T) {
assert.Equal(t, "k.keyword LIKE 'img%' OR k.keyword LIKE 'json%' OR k.keyword LIKE 'usa%'", w[1])
}
})
t.Run("and_or_search_en", func(t *testing.T) {
t.Run("SearchAndOrEnglish", func(t *testing.T) {
if w := LikeAnyWord("k.keyword", "table spoon and usa or img json"); len(w) != 2 {
t.Fatal("two where conditions expected")
} else {
@ -118,6 +118,14 @@ func TestLikeAnyWord(t *testing.T) {
assert.Equal(t, "k.keyword LIKE 'img%' OR k.keyword LIKE 'json%' OR k.keyword LIKE 'usa%'", w[1])
}
})
t.Run("EscapeSql", func(t *testing.T) {
if w := LikeAnyWord("k.keyword", "table% | 'spoon' & \"usa"); len(w) != 2 {
t.Fatalf("two where conditions expected: %#v", w)
} else {
assert.Equal(t, "k.keyword LIKE 'spoon%' OR k.keyword LIKE 'table%'", w[0])
assert.Equal(t, "k.keyword LIKE '\\\"usa%'", w[1])
}
})
}
func TestLikeAll(t *testing.T) {

View file

@ -91,7 +91,7 @@ func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {
s = s.Where("files.file_primary = 1")
}
if f.UID != "" {
if txt.NotEmpty(f.UID) {
s = s.Where("photos.photo_uid IN (?)", strings.Split(strings.ToLower(f.UID), txt.Or))
// Take shortcut?
@ -117,7 +117,7 @@ func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {
var labels []entity.Label
var labelIds []uint
if f.Label != "" {
if txt.NotEmpty(f.Label) {
if err := Db().Where(AnySlug("label_slug", f.Label, txt.Or)).Or(AnySlug("custom_slug", f.Label, txt.Or)).Find(&labels).Error; len(labels) == 0 || err != nil {
log.Debugf("search: label %s not found", txt.LogParamLower(f.Label))
return PhotoResults{}, 0, nil
@ -225,7 +225,7 @@ func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {
}
// Search for one or more keywords?
if f.Keywords != "" {
if txt.NotEmpty(f.Keywords) {
for _, where := range LikeAnyWord("k.keyword", f.Keywords) {
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
}
@ -261,7 +261,7 @@ func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {
}
// Filter for one or more subjects?
if f.Subject != "" {
if txt.NotEmpty(f.Subject) {
for _, subj := range strings.Split(strings.ToLower(f.Subject), txt.And) {
if subjects := strings.Split(subj, txt.Or); rnd.ContainsUIDs(subjects, 'j') {
s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 WHERE subj_uid IN (?))",
@ -271,7 +271,7 @@ func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(AnySlug("s.subj_slug", subj, txt.Or)))
}
}
} else if f.Subjects != "" {
} else if txt.NotEmpty(f.Subjects) {
for _, where := range LikeAllNames(Cols{"subj_name", "subj_alias"}, f.Subjects) {
s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 JOIN %s s ON s.subj_uid = m.subj_uid WHERE (?))",
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(where))
@ -301,14 +301,20 @@ func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {
}
}
// Filter by camera?
if f.Camera > 0 {
s = s.Where("photos.camera_id = ?", f.Camera)
// Filter by camera id or name?
if txt.IsPosInt(f.Camera) {
s = s.Where("photos.camera_id = ?", txt.UInt(f.Camera))
} else if txt.NotEmpty(f.Camera) {
v := strings.Trim(f.Camera, "*%") + "%"
s = s.Where("cameras.camera_make LIKE ? OR cameras.camera_model LIKE ? OR cameras.camera_slug LIKE ?", v, v, v)
}
// Filter by camera lens?
if f.Lens > 0 {
s = s.Where("photos.lens_id = ?", f.Lens)
// Filter by lens id or name?
if txt.IsPosInt(f.Lens) {
s = s.Where("photos.lens_id = ?", txt.UInt(f.Lens))
} else if txt.NotEmpty(f.Lens) {
v := strings.Trim(f.Lens, "*%") + "%"
s = s.Where("lenses.lens_make LIKE ? OR lenses.lens_model LIKE ? OR lenses.lens_slug LIKE ?", v, v, v)
}
// Filter by year?
@ -358,23 +364,23 @@ func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {
}
// Filter by location country?
if f.Country != "" {
if txt.NotEmpty(f.Country) {
s = s.Where("photos.photo_country IN (?)", strings.Split(strings.ToLower(f.Country), txt.Or))
}
// Filter by location state?
if f.State != "" {
if txt.NotEmpty(f.State) {
s = s.Where("places.place_state IN (?)", strings.Split(f.State, txt.Or))
}
// Filter by location category?
if f.Category != "" {
if txt.NotEmpty(f.Category) {
s = s.Joins("JOIN cells ON photos.cell_id = cells.id").
Where("cells.cell_category IN (?)", strings.Split(strings.ToLower(f.Category), txt.Or))
}
// Filter by media type?
if f.Type != "" {
if txt.NotEmpty(f.Type) {
s = s.Where("photos.photo_type IN (?)", strings.Split(strings.ToLower(f.Type), txt.Or))
} else if f.Video {
s = s.Where("photos.photo_type = 'video'")
@ -387,7 +393,7 @@ func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {
}
// Filter by storage path?
if f.Path != "" {
if txt.NotEmpty(f.Path) {
p := f.Path
if strings.HasPrefix(p, "/") {
@ -403,7 +409,7 @@ func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {
}
// Filter by primary file name without path and extension.
if f.Name != "" {
if txt.NotEmpty(f.Name) {
where, names := OrLike("photos.photo_name", f.Name)
// Omit file path and known extensions.
@ -415,25 +421,25 @@ func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {
}
// Filter by complete file names?
if f.Filename != "" {
if txt.NotEmpty(f.Filename) {
where, values := OrLike("files.file_name", f.Filename)
s = s.Where(where, values...)
}
// Filter by original file name?
if f.Original != "" {
if txt.NotEmpty(f.Original) {
where, values := OrLike("photos.original_name", f.Original)
s = s.Where(where, values...)
}
// Filter by photo title?
if f.Title != "" {
if txt.NotEmpty(f.Title) {
where, values := OrLike("photos.photo_title", f.Title)
s = s.Where(where, values...)
}
// Filter by file hash?
if f.Hash != "" {
if txt.NotEmpty(f.Hash) {
s = s.Where("files.file_hash IN (?)", strings.Split(strings.ToLower(f.Hash), txt.Or))
}
@ -498,11 +504,10 @@ func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {
}
} else if f.Unsorted && f.Filter == "" {
s = s.Where("photos.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 0)")
} else if f.Albums != "" || f.Album != "" {
if f.Albums == "" {
f.Albums = f.Album
}
} else if txt.NotEmpty(f.Album) {
v := strings.Trim(f.Album, "*%") + "%"
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (a.album_title LIKE ? OR a.album_slug LIKE ?))", v, v)
} else if txt.NotEmpty(f.Albums) {
for _, where := range LikeAnyWord("a.album_title", f.Albums) {
s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (?))", gorm.Expr(where))
}

View file

@ -295,7 +295,7 @@ func TestPhotos(t *testing.T) {
f.Query = ""
f.Count = 10
f.Offset = 0
f.Camera = 1000003
f.Camera = "1000003"
photos, _, err := Photos(f)
@ -603,12 +603,42 @@ func TestPhotos(t *testing.T) {
}
assert.LessOrEqual(t, 1, len(photos))
})
t.Run("search for camera name", func(t *testing.T) {
var f form.SearchPhotos
f.Query = ""
f.Count = 1
f.Offset = 0
f.Camera = "canon"
f.Lens = ""
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 1, len(photos))
})
t.Run("search for lens name", func(t *testing.T) {
var f form.SearchPhotos
f.Query = ""
f.Count = 1
f.Offset = 0
f.Camera = ""
f.Lens = "apple"
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 1, len(photos))
})
t.Run("search for lens, month, year, album", func(t *testing.T) {
var f form.SearchPhotos
f.Query = ""
f.Count = 5000
f.Offset = 0
f.Lens = 1000000
f.Lens = "1000000"
f.Month = strconv.Itoa(7)
f.Year = strconv.Itoa(2790)
f.Album = "at9lxuqxpogaaba8"
@ -1393,7 +1423,7 @@ func TestPhotos(t *testing.T) {
assert.Greater(t, len(photos), len(photos2))
})
t.Run("albums and and or search", func(t *testing.T) {
t.Run("AlbumsOrSearch", func(t *testing.T) {
var f form.SearchPhotos
f.Query = "albums:Holiday|Berlin"
@ -1403,14 +1433,19 @@ func TestPhotos(t *testing.T) {
t.Fatal(err)
}
f.Query = "albums:Berlin&Holiday"
assert.Greater(t, len(photos), 5)
})
t.Run("AlbumsAndSearch", func(t *testing.T) {
var f form.SearchPhotos
photos2, _, err2 := Photos(f)
f.Query = "albums:\"Berlin&Holiday\""
if err2 != nil {
t.Fatal(err2)
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Greater(t, len(photos), len(photos2))
assert.Greater(t, len(photos), 0)
})
t.Run("people and and or search", func(t *testing.T) {
var f form.SearchPhotos

12
internal/search/sql.go Normal file
View file

@ -0,0 +1,12 @@
package search
import (
"strings"
"github.com/photoprism/photoprism/pkg/sanitize"
)
// SqlLike escapes a string for use in an SQL query.
func SqlLike(s string) string {
return strings.Trim(sanitize.SqlString(s), " |&*%")
}

View file

@ -0,0 +1,25 @@
package search
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSqlLike(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, "", SqlLike(""))
})
t.Run("Special", func(t *testing.T) {
s := "' \" \t \n %_''"
exp := "\\' \\\" %\\_\\'\\'"
result := SqlLike(s)
t.Logf("String..: %s", s)
t.Logf("Expected: %s", exp)
t.Logf("Result..: %s", result)
assert.Equal(t, exp, result)
})
t.Run("Alnum", func(t *testing.T) {
assert.Equal(t, "123ABCabc", SqlLike(" 123ABCabc%* "))
})
}

40
pkg/sanitize/sql.go Normal file
View file

@ -0,0 +1,40 @@
package sanitize
import (
"bytes"
)
// sqlSpecialBytes contains special bytes to escape in SQL search queries.
var sqlSpecialBytes = []byte{34, 39, 92, 95}
// SqlString escapes a string for use in an SQL query.
func SqlString(s string) string {
var i int
for i = 0; i < len(s); i++ {
if bytes.Contains(sqlSpecialBytes, []byte{s[i]}) {
break
}
}
// No special characters found, return original string.
if i >= len(s) {
return s
}
b := make([]byte, 2*len(s)-i)
copy(b, s[:i])
j := i
for ; i < len(s); i++ {
if s[i] < 31 {
// Ignore control chars.
continue
}
if bytes.Contains(sqlSpecialBytes, []byte{s[i]}) {
b[j] = '\\'
j++
}
b[j] = s[i]
j++
}
return string(b[:j])
}

25
pkg/sanitize/sql_test.go Normal file
View file

@ -0,0 +1,25 @@
package sanitize
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSqlString(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, "", SqlString(""))
})
t.Run("Special", func(t *testing.T) {
s := "' \" \t \n %_''"
exp := "\\' \\\" %\\_\\'\\'"
result := SqlString(s)
t.Logf("String..: %s", s)
t.Logf("Expected: %s", exp)
t.Logf("Result..: %s", result)
assert.Equal(t, exp, result)
})
t.Run("Alnum", func(t *testing.T) {
assert.Equal(t, "123ABCabc", SqlString("123ABCabc"))
})
}

23
pkg/txt/empty.go Normal file
View file

@ -0,0 +1,23 @@
package txt
import (
"strings"
)
// IsEmpty tests if a string represents an empty/invalid value.
func IsEmpty(s string) bool {
s = strings.Trim(strings.TrimSpace(s), "%*")
if s == "" || s == "0" || s == "-1" {
return true
}
s = strings.ToLower(s)
return s == "nil" || s == "null" || s == "nan"
}
// NotEmpty tests if a string does not represent an empty/invalid value.
func NotEmpty(s string) bool {
return !IsEmpty(s)
}

97
pkg/txt/empty_test.go Normal file
View file

@ -0,0 +1,97 @@
package txt
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsEmpty(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, true, IsEmpty(""))
})
t.Run("EnNew", func(t *testing.T) {
assert.Equal(t, false, IsEmpty(EnNew))
})
t.Run("Spaces", func(t *testing.T) {
assert.Equal(t, false, IsEmpty(" new "))
})
t.Run("Uppercase", func(t *testing.T) {
assert.Equal(t, false, IsEmpty("NEW"))
})
t.Run("Lowercase", func(t *testing.T) {
assert.Equal(t, false, IsEmpty("new"))
})
t.Run("True", func(t *testing.T) {
assert.Equal(t, false, IsEmpty("New"))
})
t.Run("False", func(t *testing.T) {
assert.Equal(t, false, IsEmpty("non"))
})
t.Run("0", func(t *testing.T) {
assert.Equal(t, true, IsEmpty("0"))
})
t.Run("-1", func(t *testing.T) {
assert.Equal(t, true, IsEmpty("-1"))
})
t.Run("nil", func(t *testing.T) {
assert.Equal(t, true, IsEmpty("nil"))
})
t.Run("NaN", func(t *testing.T) {
assert.Equal(t, true, IsEmpty("NaN"))
})
t.Run("NULL", func(t *testing.T) {
assert.Equal(t, true, IsEmpty("NULL"))
})
t.Run("*", func(t *testing.T) {
assert.Equal(t, true, IsEmpty("*"))
})
t.Run("%", func(t *testing.T) {
assert.Equal(t, true, IsEmpty("%"))
})
}
func TestNotEmpty(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, false, NotEmpty(""))
})
t.Run("EnNew", func(t *testing.T) {
assert.Equal(t, true, NotEmpty(EnNew))
})
t.Run("Spaces", func(t *testing.T) {
assert.Equal(t, true, NotEmpty(" new "))
})
t.Run("Uppercase", func(t *testing.T) {
assert.Equal(t, true, NotEmpty("NEW"))
})
t.Run("Lowercase", func(t *testing.T) {
assert.Equal(t, true, NotEmpty("new"))
})
t.Run("True", func(t *testing.T) {
assert.Equal(t, true, NotEmpty("New"))
})
t.Run("False", func(t *testing.T) {
assert.Equal(t, true, NotEmpty("non"))
})
t.Run("0", func(t *testing.T) {
assert.Equal(t, false, NotEmpty("0"))
})
t.Run("-1", func(t *testing.T) {
assert.Equal(t, false, NotEmpty("-1"))
})
t.Run("nil", func(t *testing.T) {
assert.Equal(t, false, NotEmpty("nil"))
})
t.Run("NaN", func(t *testing.T) {
assert.Equal(t, false, NotEmpty("NaN"))
})
t.Run("NULL", func(t *testing.T) {
assert.Equal(t, false, NotEmpty("NULL"))
})
t.Run("*", func(t *testing.T) {
assert.Equal(t, false, NotEmpty("*"))
})
t.Run("%", func(t *testing.T) {
assert.Equal(t, false, NotEmpty("%"))
})
}

View file

@ -49,3 +49,18 @@ func IsUInt(s string) bool {
return true
}
// IsPosInt checks if a string represents an integer greater than 0.
func IsPosInt(s string) bool {
if s == "" || s == " " || s == "0" || s == "-1" {
return false
}
for _, r := range s {
if r < 48 || r > 57 {
return false
}
}
return true
}

View file

@ -38,6 +38,19 @@ func TestIsUInt(t *testing.T) {
assert.True(t, IsUInt("123"))
}
func TestIsPosInt(t *testing.T) {
assert.False(t, IsPosInt(""))
assert.False(t, IsPosInt("12 3"))
assert.True(t, IsPosInt("123"))
assert.False(t, IsPosInt(" "))
assert.False(t, IsPosInt("-1"))
assert.False(t, IsPosInt("0"))
assert.False(t, IsPosInt("0.1"))
assert.False(t, IsPosInt("0,1"))
assert.True(t, IsPosInt("1"))
assert.True(t, IsPosInt("99943546356"))
}
func TestUInt(t *testing.T) {
t.Run("empty", func(t *testing.T) {
result := UInt("")

View file

@ -15,7 +15,7 @@ func Bool(s string) bool {
return true
}
// Yes returns true if a string represents "yes".
// Yes tests if a string represents "yes".
func Yes(s string) bool {
if s == "" {
return false
@ -26,7 +26,7 @@ func Yes(s string) bool {
return strings.IndexAny(s, "ytjposiд") == 0
}
// No returns true if a string represents "no".
// No tests if a string represents "no".
func No(s string) bool {
if s == "" {
return false
@ -37,7 +37,7 @@ func No(s string) bool {
return strings.IndexAny(s, "0nhufeн") == 0
}
// New returns true if a string represents "new".
// New tests if a string represents "new".
func New(s string) bool {
if s == "" {
return false