package persistence
import (
"context"
"strings"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("sqlRestful", func() {
Describe("parseRestFilters", func() {
var r sqlRepository
var options rest.QueryOptions
BeforeEach(func() {
r = sqlRepository{}
})
It("returns nil if filters is empty", func() {
options.Filters = nil
Expect(r.parseRestFilters(context.Background(), options)).To(BeNil())
})
It(`returns nil if tries a filter with legacySearchExpr("'")`, func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy"
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter("table"),
}
options.Filters = map[string]any{"name": "'"}
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
})
It("does not add nill filters", func() {
r.filterMappings = map[string]filterFunc{
"name": func(string, any) squirrel.Sqlizer {
return nil
},
}
options.Filters = map[string]any{"name": "joe"}
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
})
It("returns a '=' condition for 'id' filter", func() {
options.Filters = map[string]any{"id": "123"}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
})
It("returns a 'in' condition for multiples 'id' filters", func() {
options.Filters = map[string]any{"id": []string{"123", "456"}}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
})
It("returns a 'like' condition for other filters", func() {
options.Filters = map[string]any{"name": "joe"}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
})
It("uses the custom filter", func() {
r.filterMappings = map[string]filterFunc{
"test": func(field string, value any) squirrel.Sqlizer {
return squirrel.Gt{field: value}
},
}
options.Filters = map[string]any{"test": 100}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
})
})
Describe("fullTextFilter function", func() {
var filter filterFunc
var tableName string
var mbidFields []string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy"
tableName = "test_table"
mbidFields = []string{"mbid", "artist_mbid"}
filter = fullTextFilter(tableName, mbidFields...)
})
Context("when value is a valid UUID", func() {
It("returns only the mbid filter (precedence over full text)", func() {
uuid := "550e8400-e29b-41d4-a716-446655440000"
result := filter("search", uuid)
expected := squirrel.Or{
squirrel.Eq{"test_table.mbid": uuid},
squirrel.Eq{"test_table.artist_mbid": uuid},
}
Expect(result).To(Equal(expected))
})
It("falls back to full text when no mbid fields are provided", func() {
noMbidFilter := fullTextFilter(tableName)
uuid := "550e8400-e29b-41d4-a716-446655440000"
result := noMbidFilter("search", uuid)
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(ContainElement("% 550e8400-e29b-41d4-a716-446655440000%"))
})
})
Context("when value is not a valid UUID", func() {
It("returns full text search condition only", func() {
result := filter("search", "beatles")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(ContainElement("% beatles%"))
})
It("handles multi-word search terms", func() {
result := filter("search", "the beatles abbey road")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(HaveLen(4))
Expect(args).To(ContainElement("% the%"))
Expect(args).To(ContainElement("% beatles%"))
Expect(args).To(ContainElement("% abbey%"))
Expect(args).To(ContainElement("% road%"))
})
})
Context("when SearchFullString config changes behavior", func() {
It("uses different separator with SearchFullString=false", func() {
conf.Server.Search.FullString = false
result := filter("search", "test query")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(HaveLen(2))
Expect(args).To(ContainElement("% test%"))
Expect(args).To(ContainElement("% query%"))
})
It("uses no separator with SearchFullString=true", func() {
conf.Server.Search.FullString = true
result := filter("search", "test query")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(HaveLen(2))
Expect(args).To(ContainElement("%test%"))
Expect(args).To(ContainElement("%query%"))
})
})
Context("single-character queries (regression: must not be rejected)", func() {
It("returns valid filter for single-char query with legacy backend", func() {
conf.Server.Search.Backend = "legacy"
result := filter("search", "a")
Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE"))
Expect(args).ToNot(BeEmpty())
})
It("returns valid filter for single-char query with FTS backend", func() {
conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = false
ftsFilter := fullTextFilter(tableName, mbidFields...)
result := ftsFilter("search", "a")
Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("MATCH"))
Expect(args).ToNot(BeEmpty())
})
})
Context("edge cases", func() {
It("returns nil for empty string", func() {
result := filter("search", "")
Expect(result).To(BeNil())
})
It("returns nil for string with only whitespace", func() {
result := filter("search", " ")
Expect(result).To(BeNil())
})
It("handles special characters that are sanitized", func() {
result := filter("search", "don't")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(ContainElement("% dont%"))
})
It("returns nil for single quote (SQL injection protection)", func() {
result := filter("search", "'")
Expect(result).To(BeNil())
})
It("handles mixed case UUIDs", func() {
uuid := "550E8400-E29B-41D4-A716-446655440000"
result := filter("search", uuid)
expected := squirrel.Or{
squirrel.Eq{"test_table.mbid": strings.ToLower(uuid)},
squirrel.Eq{"test_table.artist_mbid": strings.ToLower(uuid)},
}
Expect(result).To(Equal(expected))
})
It("handles invalid UUID format gracefully", func() {
result := filter("search", "550e8400-invalid-uuid")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(ContainElement("% 550e8400-invalid-uuid%"))
})
It("handles empty mbid fields array", func() {
emptyMbidFilter := fullTextFilter(tableName, []string{}...)
result := emptyMbidFilter("search", "test")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(ContainElement("% test%"))
})
It("converts value to lowercase before processing", func() {
result := filter("search", "TEST")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(ContainElement("% test%"))
})
})
})
})