diff --git a/.gitignore b/.gitignore index 7ba06b06..8016b4be 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /integration/dump.rdb *.swp /integration/nodes.conf +.idea/ +miniredis.iml diff --git a/cmd_list.go b/cmd_list.go index f3080140..3a55a11f 100644 --- a/cmd_list.go +++ b/cmd_list.go @@ -23,6 +23,7 @@ func commandsList(m *Miniredis) { m.srv.Register("BRPOP", m.cmdBrpop) m.srv.Register("BRPOPLPUSH", m.cmdBrpoplpush) m.srv.Register("LINDEX", m.cmdLindex) + m.srv.Register("LPOS", m.cmdLpos) m.srv.Register("LINSERT", m.cmdLinsert) m.srv.Register("LLEN", m.cmdLlen) m.srv.Register("LPOP", m.cmdLpop) @@ -165,6 +166,153 @@ func (m *Miniredis) cmdLindex(c *server.Peer, cmd string, args []string) { }) } +// LPOS key element [RANK rank] [COUNT num-matches] [MAXLEN len] +func (m *Miniredis) cmdLpos(c *server.Peer, cmd string, args []string) { + if !m.handleAuth(c) { + return + } + if m.checkPubsub(c, cmd) { + return + } + + if len(args) == 1 { + setDirty(c) + c.WriteError(errWrongNumber(cmd)) + return + } + + // Extract options from arguments if present. + // + // Redis allows duplicate options and uses the last specified. + // `LPOS key term RANK 1 RANK 2` is effectively the same as + // `LPOS key term RANK 2` + if len(args)%2 == 1 { + setDirty(c) + c.WriteError(msgSyntaxError) + return + } + rank, count := 1, 1 // Default values + var maxlen int // Default value is the list length (see below) + var countSpecified, maxlenSpecified bool + if len(args) > 2 { + for i := 2; i < len(args); i++ { + if i%2 == 0 { + val := args[i+1] + var err error + switch strings.ToLower(args[i]) { + case "rank": + if rank, err = strconv.Atoi(val); err != nil { + setDirty(c) + c.WriteError(msgInvalidInt) + return + } + if rank == 0 { + setDirty(c) + c.WriteError(msgRankIsZero) + return + } + case "count": + countSpecified = true + if count, err = strconv.Atoi(val); err != nil || count < 0 { + setDirty(c) + c.WriteError(msgCountIsNegative) + return + } + case "maxlen": + maxlenSpecified = true + if maxlen, err = strconv.Atoi(val); err != nil || maxlen < 0 { + setDirty(c) + c.WriteError(msgMaxLengthIsNegative) + return + } + default: + setDirty(c) + c.WriteError(msgSyntaxError) + return + } + } + } + } + + withTx(m, c, func(c *server.Peer, ctx *connCtx) { + db := m.db(ctx.selectedDB) + key, element := args[0], args[1] + t, ok := db.keys[key] + if !ok { + // No such key + c.WriteNull() + return + } + if t != "list" { + c.WriteError(msgWrongType) + return + } + l := db.listKeys[key] + + // RANK cannot be zero (see above). + // If RANK is positive search forward (left to right). + // If RANK is negative search backward (right to left). + // Iterator returns true to continue iterating. + iterate := func(iterator func(i int, e string) bool) { + comparisons := len(l) + // Only use max length if specified, not zero, and less than total length. + // When max length is specified, but is zero, this means "unlimited". + if maxlenSpecified && maxlen != 0 && maxlen < len(l) { + comparisons = maxlen + } + if rank > 0 { + for i := 0; i < comparisons; i++ { + if resume := iterator(i, l[i]); !resume { + return + } + } + } else if rank < 0 { + start := len(l) - 1 + end := len(l) - comparisons + for i := start; i >= end; i-- { + if resume := iterator(i, l[i]); !resume { + return + } + } + } + } + + var currentRank, currentCount int + vals := make([]int, 0, count) + iterate(func(i int, e string) bool { + if e == element { + currentRank++ + // Only collect values only after surpassing the absolute value of rank. + if rank > 0 && currentRank < rank { + return true + } + if rank < 0 && currentRank < -rank { + return true + } + vals = append(vals, i) + currentCount++ + if currentCount == count { + return false + } + } + return true + }) + + if !countSpecified && len(vals) == 0 { + c.WriteNull() + return + } + if !countSpecified && len(vals) == 1 { + c.WriteInt(vals[0]) + return + } + c.WriteLen(len(vals)) + for _, val := range vals { + c.WriteInt(val) + } + }) +} + // LINSERT func (m *Miniredis) cmdLinsert(c *server.Peer, cmd string, args []string) { if len(args) != 4 { diff --git a/cmd_list_test.go b/cmd_list_test.go index f4092359..6bc368b5 100644 --- a/cmd_list_test.go +++ b/cmd_list_test.go @@ -443,6 +443,264 @@ func TestLindex(t *testing.T) { }) } +func TestLpos(t *testing.T) { + s, err := Run() + ok(t, err) + defer s.Close() + c, err := proto.Dial(s.Addr()) + ok(t, err) + defer c.Close() + + s.Push("l", "aap", "noot", "aap", "mies", "aap", "vuur", "aap", "aap") + + // Simple LPOS. + // [aap, noot, aap, mies, aap, vuur, aap, aap] + mustDo(t, c, + "LPOS", "l", "aap", + proto.Int(0), + ) + mustDo(t, c, + "LPOS", "l", "noot", + proto.Int(1), + ) + mustDo(t, c, + "LPOS", "l", "mies", + proto.Int(3), + ) + mustDo(t, c, + "LPOS", "l", "vuur", + proto.Int(5), + ) + mustNil(t, c, "LPOS", "l", "wim") + + // LPOS with RANK option. + // [aap, noot, aap, mies, aap, vuur, aap, aap] + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "1", + proto.Int(0), + ) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "4", + proto.Int(6), + ) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "5", + proto.Int(7), + ) + mustNil(t, c, "LPOS", "l", "aap", "RANK", "6") + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "-1", + proto.Int(7), + ) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "-3", + proto.Int(4), + ) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "-5", + proto.Int(0), + ) + mustNil(t, c, "LPOS", "l", "aap", "RANK", "-6") + + // LPOS with COUNT + // When COUNT is specified always return a list. + // [aap, noot, aap, mies, aap, vuur, aap, aap] + mustDo(t, c, + "LPOS", "l", "wim", "COUNT", "1", + proto.Ints()) + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "1", + proto.Ints(0), + ) + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "3", + proto.Ints(0, 2, 4), + ) + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "5", + proto.Ints(0, 2, 4, 6, 7), + ) + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "100", + proto.Ints(0, 2, 4, 6, 7), + ) + mustDo(t, c, + // COUNT 0 means "unlimited". + "LPOS", "l", "aap", "COUNT", "0", + proto.Ints(0, 2, 4, 6, 7), + ) + + // LPOS with RANK and COUNT + // [aap, noot, aap, mies, aap, vuur, aap, aap] + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "3", "COUNT", "2", + proto.Ints(4, 6), + ) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "3", "COUNT", "3", + proto.Ints(4, 6, 7), + ) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "5", "COUNT", "100", + proto.Ints(7), + ) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "-3", "COUNT", "2", + proto.Ints(4, 2), + ) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "-3", "COUNT", "3", + proto.Ints(4, 2, 0), + ) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "-5", "COUNT", "100", + proto.Ints(0), + ) + + // LPOS with RANK and MAXLEN + // [aap, noot, aap, mies, aap, vuur, aap, aap] + mustNil(t, c, "LPOS", "l", "aap", "RANK", "4", "MAXLEN", "6") + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "4", "MAXLEN", "7", + proto.Int(6), + ) + mustNil(t, c, "LPOS", "l", "aap", "RANK", "-4", "MAXLEN", "5") + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "-4", "MAXLEN", "6", + proto.Int(2), + ) + + // LPOS with COUNT and MAXLEN + // [aap, noot, aap, mies, aap, vuur, aap, aap] + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "0", "MAXLEN", "1", + proto.Ints(0), + ) + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "0", "MAXLEN", "4", + proto.Ints(0, 2), + ) + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "0", "MAXLEN", "7", + proto.Ints(0, 2, 4, 6), + ) + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "0", "MAXLEN", "8", + proto.Ints(0, 2, 4, 6, 7), + ) + mustDo(t, c, + // MAXLEN 0 means "unlimited". + "LPOS", "l", "aap", "COUNT", "0", "MAXLEN", "0", + proto.Ints(0, 2, 4, 6, 7), + ) + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "2", "MAXLEN", "0", + proto.Ints(0, 2), + ) + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "1", "MAXLEN", "0", + proto.Ints(0), + ) + + // LPOS with RANK, COUNT, and MAXLEN + // [aap, noot, aap, mies, aap, vuur, aap, aap] + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "4", "COUNT", "2", "MAXLEN", "0", + proto.Ints(6, 7)) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "4", "COUNT", "2", "MAXLEN", "7", + proto.Ints(6)) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "4", "COUNT", "2", "MAXLEN", "6", + proto.Ints()) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "-3", "COUNT", "2", "MAXLEN", "0", + proto.Ints(4, 2)) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "-3", "COUNT", "2", "MAXLEN", "4", + proto.Ints(4)) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "-3", "COUNT", "2", "MAXLEN", "3", + proto.Ints()) + + t.Run("errors", func(t *testing.T) { + // Wrong type of key. + mustOK(t, c, "SET", "str", "value") + mustDo(t, c, + "LPOS", "str", "value", + proto.Error(msgWrongType), + ) + + // Wrong number of arguments. + mustDo(t, c, + "LPOS", "l", + proto.Error("ERR wrong number of arguments for 'lpos' command"), + ) + + // Wrong number of options. + mustDo(t, c, + "LPOS", "l", "aap", "RANK", + proto.Error("ERR syntax error"), + ) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "1", "COUNT", + proto.Error("ERR syntax error"), + ) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "1", "COUNT", "1", "MAXLEN", + proto.Error("ERR syntax error"), + ) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "1", "COUNT", "1", "MAXLEN", "1", "RANK", + proto.Error("ERR syntax error"), + ) + + // Invalid options. + mustDo(t, c, + "LPOS", "l", "aap", "RANKS", "1", + proto.Error("ERR syntax error")) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "1", "COUNTING", "1", + proto.Error("ERR syntax error")) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "1", "MAXLENGTH", "1", + proto.Error("ERR syntax error")) + + // Invalid option values. + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "not_an_int", + proto.Error("ERR value is not an integer or out of range")) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "0", + proto.Error("ERR RANK can't be zero: use 1 to start from the first match, 2 from the second ... or use negative to start from the end of the list")) + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "-1", + proto.Error("ERR COUNT can't be negative")) + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "not_an_int", + // Redis (incorrectly?) reports this as a negative number. + proto.Error("ERR COUNT can't be negative")) + mustDo(t, c, + "LPOS", "l", "aap", "MAXLEN", "-1", + proto.Error("ERR MAXLEN can't be negative")) + mustDo(t, c, + "LPOS", "l", "aap", "MAXLEN", "not_an_int", + // Redis (incorrectly?) reports this as a negative number. + proto.Error("ERR MAXLEN can't be negative")) + + // First invalid option encountered reports the error. + mustDo(t, c, + "LPOS", "l", "aap", "MAXLEN", "-1", "RANK", "not_an_int", "COUNT", "-1", + proto.Error("ERR MAXLEN can't be negative")) + mustDo(t, c, + "LPOS", "l", "aap", "RANK", "not_an_int", "COUNT", "-1", "MAXLEN", "-1", + proto.Error("ERR value is not an integer or out of range")) + mustDo(t, c, + "LPOS", "l", "aap", "COUNT", "-1", "MAXLEN", "-1", "RANK", "not_an_int", + proto.Error("ERR COUNT can't be negative")) + }) +} + func TestLlen(t *testing.T) { s, err := Run() ok(t, err) diff --git a/integration/list_test.go b/integration/list_test.go index 4389d716..9b4f3b38 100644 --- a/integration/list_test.go +++ b/integration/list_test.go @@ -163,6 +163,74 @@ func TestLinxed(t *testing.T) { }) } +func TestLpos(t *testing.T) { + testRaw(t, func(c *client) { + c.Do("RPUSH", "l", "aap", "noot", "aap", "mies", "aap", "vuur", "aap", "aap") + c.Do("LPOS", "l", "app") + c.Do("LPOS", "l", "noot") + c.Do("LPOS", "l", "mies") + c.Do("LPOS", "l", "vuur") + c.Do("LPOS", "l", "wim") + c.Do("LPOS", "l", "app", "RANK", "1") + c.Do("LPOS", "l", "app", "RANK", "4") + c.Do("LPOS", "l", "app", "RANK", "5") + c.Do("LPOS", "l", "app", "RANK", "6") + c.Do("LPOS", "l", "app", "RANK", "-1") + c.Do("LPOS", "l", "app", "RANK", "-4") + c.Do("LPOS", "l", "app", "RANK", "-5") + c.Do("LPOS", "l", "app", "RANK", "-6") + c.Do("LPOS", "l", "wim", "COUNT", "1") + c.Do("LPOS", "l", "aap", "COUNT", "1") + c.Do("LPOS", "l", "aap", "COUNT", "3") + c.Do("LPOS", "l", "aap", "COUNT", "5") + c.Do("LPOS", "l", "aap", "COUNT", "100") + c.Do("LPOS", "l", "aap", "COUNT", "0") + c.Do("LPOS", "l", "aap", "RANK", "3", "COUNT", "2") + c.Do("LPOS", "l", "aap", "RANK", "3", "COUNT", "3") + c.Do("LPOS", "l", "aap", "RANK", "5", "COUNT", "100") + c.Do("LPOS", "l", "aap", "RANK", "-3", "COUNT", "2") + c.Do("LPOS", "l", "aap", "RANK", "-3", "COUNT", "3") + c.Do("LPOS", "l", "aap", "RANK", "-5", "COUNT", "100") + c.Do("LPOS", "l", "aap", "RANK", "4", "MAXLEN", "6") + c.Do("LPOS", "l", "aap", "RANK", "4", "MAXLEN", "7") + c.Do("LPOS", "l", "aap", "RANK", "-4", "MAXLEN", "5") + c.Do("LPOS", "l", "aap", "RANK", "-4", "MAXLEN", "6") + c.Do("LPOS", "l", "aap", "COUNT", "0", "MAXLEN", "1") + c.Do("LPOS", "l", "aap", "COUNT", "0", "MAXLEN", "4") + c.Do("LPOS", "l", "aap", "COUNT", "0", "MAXLEN", "7") + c.Do("LPOS", "l", "aap", "COUNT", "0", "MAXLEN", "8") + c.Do("LPOS", "l", "aap", "COUNT", "2", "MAXLEN", "0") + c.Do("LPOS", "l", "aap", "COUNT", "1", "MAXLEN", "0") + c.Do("LPOS", "l", "aap", "RANK", "4", "COUNT", "2", "MAXLEN", "0") + c.Do("LPOS", "l", "aap", "RANK", "4", "COUNT", "2", "MAXLEN", "7") + c.Do("LPOS", "l", "aap", "RANK", "4", "COUNT", "2", "MAXLEN", "6") + c.Do("LPOS", "l", "aap", "RANK", "-3", "COUNT", "2", "MAXLEN", "0") + c.Do("LPOS", "l", "aap", "RANK", "-3", "COUNT", "2", "MAXLEN", "4") + c.Do("LPOS", "l", "aap", "RANK", "-3", "COUNT", "2", "MAXLEN", "3") + + // failure cases + c.Do("SET", "str", "I am a string") + c.Error("wrong kind", "LPOS", "str", "aap") + c.Error("wrong number", "LPOS", "l") + c.Error("syntax error", "LPOS", "l", "aap", "RANK") + c.Error("syntax error", "LPOS", "l", "aap", "RANK", "1", "COUNT") + c.Error("syntax error", "LPOS", "l", "aap", "RANK", "1", "COUNT", "1", "MAXLEN") + c.Error("syntax error", "LPOS", "l", "aap", "RANK", "1", "COUNT", "1", "MAXLEN", "1", "RANK") + c.Error("syntax error", "LPOS", "l", "aap", "RANKS", "1") + c.Error("syntax error", "LPOS", "l", "aap", "RANK", "1", "COUNTING", "1") + c.Error("syntax error", "LPOS", "l", "aap", "RANK", "1", "MAXLENGTH", "1") + c.Error("not an integer", "LPOS", "l", "aap", "RANK", "not_an_int") + c.Error("can't be zero", "LPOS", "l", "aap", "RANK", "0") + c.Error("can't be negative", "LPOS", "l", "aap", "COUNT", "-1") + c.Error("can't be negative", "LPOS", "l", "aap", "COUNT", "not_an_int") + c.Error("can't be negative", "LPOS", "l", "aap", "MAXLEN", "-1") + c.Error("can't be negative", "LPOS", "l", "aap", "MAXLEN", "not_an_int") + c.Error("can't be negative", "LPOS", "l", "aap", "MAXLEN", "-1", "RANK", "not_an_int", "COUNT", "-1") + c.Error("not an integer", "LPOS", "l", "aap", "RANK", "not_an_int", "COUNT", "-1", "MAXLEN", "-1") + c.Error("can't be negative", "LPOS", "l", "aap", "COUNT", "-1", "MAXLEN", "-1", "RANK", "not_an_int") + }) +} + func TestLlen(t *testing.T) { testRaw(t, func(c *client) { c.Do("RPUSH", "l", "aap", "noot", "mies") diff --git a/redis.go b/redis.go index d4a0cd8d..a0604803 100644 --- a/redis.go +++ b/redis.go @@ -49,6 +49,9 @@ const ( msgXtrimInvalidLimit = "ERR syntax error, LIMIT cannot be used without the special ~ option" msgDBIndexOutOfRange = "ERR DB index is out of range" msgLimitCombination = "ERR syntax error, LIMIT is only supported in combination with either BYSCORE or BYLEX" + msgRankIsZero = "ERR RANK can't be zero: use 1 to start from the first match, 2 from the second ... or use negative to start from the end of the list" + msgCountIsNegative = "ERR COUNT can't be negative" + msgMaxLengthIsNegative = "ERR MAXLEN can't be negative" ) func errWrongNumber(cmd string) string {