diff --git a/core/playlists.go b/core/playlists.go index f795be24..da99f9bd 100644 --- a/core/playlists.go +++ b/core/playlists.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" ) @@ -96,9 +97,10 @@ func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.R return nil, err } - pls.Rules = &model.SmartPlaylist{} + pls.Rules = &criteria.Criteria{} err = json.Unmarshal(content, pls.Rules) if err != nil { + log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err) return nil, err } return pls, nil diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 01eb64b4..5285deb3 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -3,6 +3,7 @@ package criteria import ( "encoding/json" + "strings" "github.com/Masterminds/squirrel" ) @@ -13,10 +14,18 @@ type Criteria struct { Expression Sort string Order string - Max int + Limit int Offset int } +func (c Criteria) OrderBy() string { + f := fieldMap[strings.ToLower(c.Sort)] + if c.Order != "" { + f = f + " " + c.Order + } + return f +} + func (c Criteria) ToSql() (sql string, args []interface{}, err error) { return c.Expression.ToSql() } @@ -27,12 +36,12 @@ func (c Criteria) MarshalJSON() ([]byte, error) { Any []Expression `json:"any,omitempty"` Sort string `json:"sort"` Order string `json:"order,omitempty"` - Max int `json:"max,omitempty"` - Offset int `json:"offset"` + Limit int `json:"limit"` + Offset int `json:"offset,omitempty"` }{ Sort: c.Sort, Order: c.Order, - Max: c.Max, + Limit: c.Limit, Offset: c.Offset, } switch rules := c.Expression.(type) { @@ -48,11 +57,11 @@ func (c Criteria) MarshalJSON() ([]byte, error) { func (c *Criteria) UnmarshalJSON(data []byte) error { var aux struct { - All unmarshalConjunctionType `json:"all,omitempty"` - Any unmarshalConjunctionType `json:"any,omitempty"` + All unmarshalConjunctionType `json:"all"` + Any unmarshalConjunctionType `json:"any"` Sort string `json:"sort"` - Order string `json:"order,omitempty"` - Max int `json:"max,omitempty"` + Order string `json:"order"` + Limit int `json:"limit"` Offset int `json:"offset"` } if err := json.Unmarshal(data, &aux); err != nil { @@ -65,7 +74,7 @@ func (c *Criteria) UnmarshalJSON(data []byte) error { } c.Sort = aux.Sort c.Order = aux.Order - c.Max = aux.Max + c.Limit = aux.Limit c.Offset = aux.Offset return nil } diff --git a/model/criteria/criteria_suite_test.go b/model/criteria/criteria_suite_test.go index d07d6084..bc26fe1b 100644 --- a/model/criteria/criteria_suite_test.go +++ b/model/criteria/criteria_suite_test.go @@ -5,13 +5,11 @@ import ( _ "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo" "github.com/onsi/gomega" ) func TestCriteria(t *testing.T) { - tests.Init(t, true) log.SetLevel(log.LevelCritical) gomega.RegisterFailHandler(Fail) RunSpecs(t, "Criteria Suite") diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index d327328d..26644c42 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -27,7 +27,7 @@ var _ = Describe("Criteria", func() { }, Sort: "title", Order: "asc", - Max: 20, + Limit: 20, Offset: 10, } var b bytes.Buffer @@ -49,7 +49,7 @@ var _ = Describe("Criteria", func() { ], "sort": "title", "order": "asc", - "max": 20, + "limit": 20, "offset": 10 } `)) diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index cdb8be6c..886a6ac5 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -35,7 +35,7 @@ var _ = Describe("Operators", func() { Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart), // TODO These may be flaky Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", startOfPeriod(30, time.Now())), - Entry("notInPeriod", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", startOfPeriod(30, time.Now())), + Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", startOfPeriod(30, time.Now())), ) DescribeTable("JSON Conversion", diff --git a/model/playlist.go b/model/playlist.go index 3ff276fb..71cc3726 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -4,6 +4,7 @@ import ( "strconv" "time" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/utils" ) @@ -23,12 +24,12 @@ type Playlist struct { UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // SmartPlaylist attributes - Rules *SmartPlaylist `structs:"-" json:"rules"` - EvaluatedAt time.Time `structs:"evaluated_at" json:"evaluatedAt"` + Rules *criteria.Criteria `structs:"-" json:"rules"` + EvaluatedAt time.Time `structs:"evaluated_at" json:"evaluatedAt"` } func (pls Playlist) IsSmartPlaylist() bool { - return pls.Rules != nil && pls.Rules.Combinator != "" + return pls.Rules != nil && pls.Rules.Expression != nil } func (pls Playlist) MediaFiles() MediaFiles { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 0de29851..3ea122cc 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -11,6 +11,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/utils" ) @@ -143,12 +144,12 @@ func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) { func (r *playlistRepository) toModel(pls dbPlaylist) (*model.Playlist, error) { var err error if strings.TrimSpace(pls.RawRules) != "" { - r := model.SmartPlaylist{} - err = json.Unmarshal([]byte(pls.RawRules), &r) + var c criteria.Criteria + err = json.Unmarshal([]byte(pls.RawRules), &c) if err != nil { return nil, err } - pls.Playlist.Rules = &r + pls.Playlist.Rules = &c } else { pls.Playlist.Rules = nil } @@ -190,13 +191,13 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { } // Re-populate playlist based on Smart Playlist criteria - sp := smartPlaylist(*pls.Rules) - sql := Select("row_number() over (order by "+sp.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). + rules := *pls.Rules + sql := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). From("media_file").LeftJoin("annotation on (" + "annotation.item_id = media_file.id" + " AND annotation.item_type = 'media_file'" + " AND annotation.user_id = '" + userId(r.ctx) + "')") - sql = sp.AddCriteria(sql) + sql = r.addCriteria(sql, rules) insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sql) c, err := r.executeSQL(insSql) if err != nil { @@ -224,6 +225,14 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { return true } +func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder { + sql = sql.Where(c.ToSql()).Limit(uint64(c.Limit)).Offset(uint64(c.Offset)) + if order := c.OrderBy(); order != "" { + sql = sql.OrderBy(order) + } + return sql +} + func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { ids := make([]string, len(tracks)) for i := range tracks { diff --git a/persistence/sql_smartplaylist.go b/persistence/sql_smartplaylist.go deleted file mode 100644 index e0f150bc..00000000 --- a/persistence/sql_smartplaylist.go +++ /dev/null @@ -1,288 +0,0 @@ -package persistence - -import ( - "errors" - "fmt" - "reflect" - "strconv" - "strings" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" -) - -//{ -//"combinator": "and", -//"rules": [ -// {"field": "lastPlayed", "operator": "in the last", "value": "30"} -//], -//"order": "lastPlayed desc", -//"limit": 10 -//} -type smartPlaylist model.SmartPlaylist - -func (sp smartPlaylist) AddCriteria(sql SelectBuilder) SelectBuilder { - sql = sql.Where(RuleGroup(sp.RuleGroup)).Limit(uint64(sp.Limit)) - if order := sp.OrderBy(); order != "" { - sql = sql.OrderBy(order) - } - return sql -} - -func (sp smartPlaylist) OrderBy() string { - order := strings.ToLower(sp.Order) - for f, fieldDef := range fieldMap { - if strings.HasPrefix(order, f) { - order = strings.Replace(order, f, fieldDef.dbField, 1) - } - } - return order -} - -type fieldDef struct { - dbField string - ruleType reflect.Type -} - -var fieldMap = map[string]*fieldDef{ - "title": {"media_file.title", stringRuleType}, - "album": {"media_file.album", stringRuleType}, - "artist": {"media_file.artist", stringRuleType}, - "albumartist": {"media_file.album_artist", stringRuleType}, - "albumartwork": {"media_file.has_cover_art", stringRuleType}, - "tracknumber": {"media_file.track_number", numberRuleType}, - "discnumber": {"media_file.disc_number", numberRuleType}, - "year": {"media_file.year", numberRuleType}, - "size": {"media_file.size", numberRuleType}, - "compilation": {"media_file.compilation", boolRuleType}, - "dateadded": {"media_file.created_at", dateRuleType}, - "datemodified": {"media_file.updated_at", dateRuleType}, - "discsubtitle": {"media_file.disc_subtitle", stringRuleType}, - "comment": {"media_file.comment", stringRuleType}, - "lyrics": {"media_file.lyrics", stringRuleType}, - "sorttitle": {"media_file.sort_title", stringRuleType}, - "sortalbum": {"media_file.sort_album_name", stringRuleType}, - "sortartist": {"media_file.sort_artist_name", stringRuleType}, - "sortalbumartist": {"media_file.sort_album_artist_name", stringRuleType}, - "albumtype": {"media_file.mbz_album_type", stringRuleType}, - "albumcomment": {"media_file.mbz_album_comment", stringRuleType}, - "catalognumber": {"media_file.catalog_num", stringRuleType}, - "filepath": {"media_file.path", stringRuleType}, - "filetype": {"media_file.suffix", stringRuleType}, - "duration": {"media_file.duration", numberRuleType}, - "bitrate": {"media_file.bit_rate", numberRuleType}, - "bpm": {"media_file.bpm", numberRuleType}, - "channels": {"media_file.channels", numberRuleType}, - "genre": {"genre.name", stringRuleType}, - "loved": {"annotation.starred", boolRuleType}, - "lastplayed": {"annotation.play_date", dateRuleType}, - "playcount": {"annotation.play_count", numberRuleType}, - "rating": {"annotation.rating", numberRuleType}, -} - -var stringRuleType = reflect.TypeOf(stringRule{}) - -type stringRule model.Rule - -func (r stringRule) ToSql() (sql string, args []interface{}, err error) { - var sq Sqlizer - switch r.Operator { - case "is": - sq = Eq{r.Field: r.Value} - case "is not": - sq = NotEq{r.Field: r.Value} - case "contains": - sq = ILike{r.Field: fmt.Sprintf("%%%s%%", r.Value)} - case "does not contains": - sq = NotILike{r.Field: fmt.Sprintf("%%%s%%", r.Value)} - case "begins with": - sq = ILike{r.Field: fmt.Sprintf("%s%%", r.Value)} - case "ends with": - sq = ILike{r.Field: fmt.Sprintf("%%%s", r.Value)} - default: - return "", nil, errors.New("operator not supported: " + r.Operator) - } - return sq.ToSql() -} - -var numberRuleType = reflect.TypeOf(numberRule{}) - -type numberRule model.Rule - -func (r numberRule) ToSql() (sql string, args []interface{}, err error) { - var sq Sqlizer - switch r.Operator { - case "is": - sq = Eq{r.Field: r.Value} - case "is not": - sq = NotEq{r.Field: r.Value} - case "is greater than": - sq = Gt{r.Field: r.Value} - case "is less than": - sq = Lt{r.Field: r.Value} - case "is in the range": - s := reflect.ValueOf(r.Value) - if s.Kind() != reflect.Slice || s.Len() != 2 { - return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", r.Value) - } - sq = And{ - GtOrEq{r.Field: s.Index(0).Interface()}, - LtOrEq{r.Field: s.Index(1).Interface()}, - } - default: - return "", nil, errors.New("operator not supported: " + r.Operator) - } - return sq.ToSql() -} - -var dateRuleType = reflect.TypeOf(dateRule{}) - -type dateRule model.Rule - -func (r dateRule) ToSql() (string, []interface{}, error) { - var date time.Time - var err error - var sq Sqlizer - switch r.Operator { - case "is": - date, err = r.parseDate(r.Value) - sq = Eq{r.Field: date} - case "is not": - date, err = r.parseDate(r.Value) - sq = NotEq{r.Field: date} - case "is before": - date, err = r.parseDate(r.Value) - sq = Lt{r.Field: date} - case "is after": - date, err = r.parseDate(r.Value) - sq = Gt{r.Field: date} - case "is in the range": - var dates []time.Time - if dates, err = r.parseDates(); err == nil { - sq = And{GtOrEq{r.Field: dates[0]}, LtOrEq{r.Field: dates[1]}} - } - case "in the last": - sq, err = r.inTheLast(false) - case "not in the last": - sq, err = r.inTheLast(true) - default: - err = errors.New("operator not supported: " + r.Operator) - } - if err != nil { - return "", nil, err - } - return sq.ToSql() -} - -func (r dateRule) inTheLast(invert bool) (Sqlizer, error) { - str := fmt.Sprintf("%v", r.Value) - v, err := strconv.ParseInt(str, 10, 64) - if err != nil { - return nil, err - } - period := time.Now().Add(time.Duration(-24*v) * time.Hour) - if invert { - return Or{ - Lt{r.Field: period}, - Eq{r.Field: nil}, - }, nil - } - return Gt{r.Field: period}, nil -} - -func (r dateRule) parseDate(date interface{}) (time.Time, error) { - input, ok := date.(string) - if !ok { - return time.Time{}, fmt.Errorf("invalid date: %v", date) - } - d, err := time.Parse("2006-01-02", input) - if err != nil { - return time.Time{}, fmt.Errorf("invalid date: %v", date) - } - return d, nil -} - -func (r dateRule) parseDates() ([]time.Time, error) { - input, ok := r.Value.([]string) - if !ok { - return nil, fmt.Errorf("invalid date range: %s", r.Value) - } - var dates []time.Time - for _, s := range input { - d, err := r.parseDate(s) - if err != nil { - return nil, fmt.Errorf("invalid date '%v' in range %v", s, input) - } - dates = append(dates, d) - } - if len(dates) != 2 { - return nil, fmt.Errorf("not a valid date range: %s", r.Value) - } - return dates, nil -} - -var boolRuleType = reflect.TypeOf(boolRule{}) - -type boolRule model.Rule - -func (r boolRule) ToSql() (sql string, args []interface{}, err error) { - var sq Sqlizer - switch r.Operator { - case "is true": - sq = Eq{r.Field: true} - case "is false": - sq = Eq{r.Field: false} - default: - return "", nil, errors.New("operator not supported: " + r.Operator) - } - return sq.ToSql() -} - -type RuleGroup model.RuleGroup - -func (rg RuleGroup) ToSql() (sql string, args []interface{}, err error) { - var sq []Sqlizer - for _, r := range rg.Rules { - switch rr := r.(type) { - case model.Rule: - sq = append(sq, rg.ruleToSqlizer(rr)) - case model.RuleGroup: - sq = append(sq, RuleGroup(rr)) - } - } - var group Sqlizer - if strings.ToLower(rg.Combinator) == "and" { - group = And(sq) - } else { - group = Or(sq) - } - return group.ToSql() -} - -type errorSqlizer string - -func (e errorSqlizer) ToSql() (sql string, args []interface{}, err error) { - return "", nil, errors.New(string(e)) -} - -func (rg RuleGroup) ruleToSqlizer(r model.Rule) Sqlizer { - ruleDef := fieldMap[strings.ToLower(r.Field)] - if ruleDef == nil { - return errorSqlizer(fmt.Sprintf("invalid smart playlist field '%s'", r.Field)) - } - r.Field = ruleDef.dbField - r.Operator = strings.ToLower(r.Operator) - switch ruleDef.ruleType { - case stringRuleType: - return stringRule(r) - case numberRuleType: - return numberRule(r) - case boolRuleType: - return boolRule(r) - case dateRuleType: - return dateRule(r) - default: - return errorSqlizer("invalid smart playlist rule type" + ruleDef.ruleType.String()) - } -} diff --git a/persistence/sql_smartplaylist_test.go b/persistence/sql_smartplaylist_test.go deleted file mode 100644 index 3c181cce..00000000 --- a/persistence/sql_smartplaylist_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package persistence - -import ( - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo" - . "github.com/onsi/ginkgo/extensions/table" - . "github.com/onsi/gomega" -) - -var _ = Describe("smartPlaylist", func() { - var pls smartPlaylist - Describe("AddCriteria", func() { - BeforeEach(func() { - sp := model.SmartPlaylist{ - RuleGroup: model.RuleGroup{ - Combinator: "and", Rules: model.Rules{ - model.Rule{Field: "title", Operator: "contains", Value: "love"}, - model.Rule{Field: "year", Operator: "is in the range", Value: []int{1980, 1989}}, - model.Rule{Field: "loved", Operator: "is true"}, - model.Rule{Field: "lastPlayed", Operator: "in the last", Value: "30"}, - model.RuleGroup{ - Combinator: "or", - Rules: model.Rules{ - model.Rule{Field: "artist", Operator: "is not", Value: "zé"}, - model.Rule{Field: "album", Operator: "is", Value: "4"}, - }, - }, - }}, - Order: "artist asc", - Limit: 100, - } - pls = smartPlaylist(sp) - }) - - It("returns a proper SQL query", func() { - sel := pls.AddCriteria(squirrel.Select("media_file").Columns("*")) - sql, args, err := sel.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal("SELECT media_file, * WHERE (media_file.title ILIKE ? AND (media_file.year >= ? AND media_file.year <= ?) AND annotation.starred = ? AND annotation.play_date > ? AND (media_file.artist <> ? OR media_file.album = ?)) ORDER BY media_file.artist asc LIMIT 100")) - lastMonth := time.Now().Add(-30 * 24 * time.Hour) - Expect(args).To(ConsistOf("%love%", 1980, 1989, true, BeTemporally("~", lastMonth, time.Second), "zé", "4")) - }) - It("returns an error if field is invalid", func() { - r := pls.Rules[0].(model.Rule) - r.Field = "INVALID" - pls.Rules[0] = r - sel := pls.AddCriteria(squirrel.Select("media_file").Columns("*")) - _, _, err := sel.ToSql() - Expect(err).To(MatchError("invalid smart playlist field 'INVALID'")) - }) - }) - - Describe("fieldMap", func() { - It("includes all possible fields", func() { - for _, field := range model.SmartPlaylistFields { - Expect(fieldMap).To(HaveKey(field)) - } - }) - It("does not have extra fields", func() { - for field := range fieldMap { - Expect(model.SmartPlaylistFields).To(ContainElement(field)) - } - }) - }) - - Describe("stringRule", func() { - DescribeTable("stringRule", - func(operator, expectedSql, expectedValue string) { - r := stringRule{Field: "title", Operator: operator, Value: "value"} - sql, args, err := r.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal(expectedSql)) - Expect(args).To(ConsistOf(expectedValue)) - }, - Entry("is", "is", "title = ?", "value"), - Entry("is not", "is not", "title <> ?", "value"), - Entry("contains", "contains", "title ILIKE ?", "%value%"), - Entry("does not contains", "does not contains", "title NOT ILIKE ?", "%value%"), - Entry("begins with", "begins with", "title ILIKE ?", "value%"), - Entry("ends with", "ends with", "title ILIKE ?", "%value"), - ) - }) - - Describe("numberRule", func() { - DescribeTable("operators", - func(operator, expectedSql string, expectedValue int) { - r := numberRule{Field: "year", Operator: operator, Value: 1985} - sql, args, err := r.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal(expectedSql)) - Expect(args).To(ConsistOf(expectedValue)) - }, - Entry("is", "is", "year = ?", 1985), - Entry("is not", "is not", "year <> ?", 1985), - Entry("is greater than", "is greater than", "year > ?", 1985), - Entry("is less than", "is less than", "year < ?", 1985), - ) - - It("implements the 'is in the range' operator", func() { - r := numberRule{Field: "year", Operator: "is in the range", Value: []int{1981, 1990}} - sql, args, err := r.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal("(year >= ? AND year <= ?)")) - Expect(args).To(ConsistOf(1981, 1990)) - }) - }) - - Describe("dateRule", func() { - delta := 30 * time.Hour // Must be large to account for the hours of the day - dateStr := time.Now().Format("2006-01-02") - date, _ := time.Parse("2006-01-02", dateStr) - DescribeTable("simple operators", - func(operator, expectedSql string, expectedValue time.Time) { - r := dateRule{Field: "lastPlayed", Operator: operator, Value: dateStr} - sql, args, err := r.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal(expectedSql)) - Expect(args).To(ConsistOf(expectedValue)) - }, - Entry("is", "is", "lastPlayed = ?", date), - Entry("is not", "is not", "lastPlayed <> ?", date), - Entry("is before", "is before", "lastPlayed < ?", date), - Entry("is after", "is after", "lastPlayed > ?", date), - ) - - DescribeTable("period operators", - func(operator, expectedSql string, expectedValue time.Time) { - r := dateRule{Field: "lastPlayed", Operator: operator, Value: 90} - sql, args, err := r.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal(expectedSql)) - Expect(args).To(ConsistOf(BeTemporally("~", expectedValue, delta))) - }, - Entry("in the last", "in the last", "lastPlayed > ?", date.Add(-90*24*time.Hour)), - Entry("not in the last", "not in the last", "(lastPlayed < ? OR lastPlayed IS NULL)", date.Add(-90*24*time.Hour)), - ) - - It("accepts string as the 'in the last' operator value", func() { - r := dateRule{Field: "lastPlayed", Operator: "in the last", Value: "90"} - _, args, _ := r.ToSql() - Expect(args).To(ConsistOf(BeTemporally("~", date.Add(-90*24*time.Hour), delta))) - }) - - It("implements the 'is in the range' operator", func() { - date2Str := time.Now().Add(48 * time.Hour).Format("2006-01-02") - date2, _ := time.Parse("2006-01-02", date2Str) - - r := dateRule{Field: "lastPlayed", Operator: "is in the range", Value: []string{date2Str, dateStr}} - sql, args, err := r.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal("(lastPlayed >= ? AND lastPlayed <= ?)")) - Expect(args).To(ConsistOf(BeTemporally("~", date2, 24*time.Hour), BeTemporally("~", date, delta))) - }) - - It("returns error if date is invalid", func() { - r := dateRule{Field: "lastPlayed", Operator: "is", Value: "INVALID"} - _, _, err := r.ToSql() - Expect(err).To(MatchError("invalid date: INVALID")) - }) - }) - - Describe("boolRule", func() { - DescribeTable("operators", - func(operator, expectedSql string, expectedValue ...interface{}) { - r := boolRule{Field: "loved", Operator: operator} - sql, args, err := r.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal(expectedSql)) - Expect(args).To(ConsistOf(expectedValue...)) - }, - Entry("is true", "is true", "loved = ?", true), - Entry("is false", "is false", "loved = ?", false), - ) - }) -})