diff --git a/README.md b/README.md index 413eee6..af9f130 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ redis-cli shutdown |started|feature impl|complete|feature| |:-:|:-:|:-:|:--| -|[x]|[ ]|[ ]|Keys| +|[x]|[x]|[ ]|Keys| |[x]|[x]|[ ]|Strings| |[x]|[x]|[ ]|Lists| |[x]|[x]|[ ]|Sets| @@ -64,6 +64,7 @@ redis-cli shutdown |[x]|[x]|[ ]|Hashes| |[ ]|[ ]|[ ]|HyperLogLog| |[ ]|[ ]|[ ]|sscan, hscan| +|[ ]|[ ]|[ ]|sort, sortNStore| #### Features |started|feature impl|complete|feature| diff --git a/src/main/scala/com/github/mogproject/redismock/MockOperations.scala b/src/main/scala/com/github/mogproject/redismock/MockOperations.scala index a2fc0c0..79d1b6e 100644 --- a/src/main/scala/com/github/mogproject/redismock/MockOperations.scala +++ b/src/main/scala/com/github/mogproject/redismock/MockOperations.scala @@ -2,13 +2,17 @@ package com.github.mogproject.redismock import com.github.mogproject.redismock.entity.Key import com.github.mogproject.redismock.storage.Storage -import com.redis.{Operations, Redis, serialization} -import com.redis.serialization.Format -import com.redis.serialization.Parse -import serialization._ +import com.github.mogproject.redismock.util.StringUtil +import com.github.mogproject.redismock.util.ops._ +import com.redis.{Operations, Redis} +import com.redis.serialization.{Format, Parse} +import scala.util.Random trait MockOperations extends Operations with Storage { self: Redis => + + lazy val random = new Random(12345L) + // SORT // sort keys in a set, and optionally pull values for them override def sort[A](key:String, @@ -30,23 +34,32 @@ trait MockOperations extends Operations with Storage { self: Redis => // KEYS // returns all the keys matching the glob-style pattern. - override def keys[A](pattern: Any = "*")(implicit format: Format, parse: Parse[A]): Option[List[Option[A]]] = ??? + override def keys[A](pattern: Any = "*")(implicit format: Format, parse: Parse[A]): Option[List[Option[A]]] = { + val r = StringUtil.globToRegex(pattern.toString) + println(r) + Some(currentDB.keys.withFilter(k => k.k.toString.matches(r)).map(k => k.k.parseOption(parse)).toList) + } // RANDKEY // return a randomly selected key from the currently selected DB. - @deprecated("use randomkey", "2.8") override def randkey[A](implicit parse: Parse[A]): Option[A] = ??? + @deprecated("use randomkey", "2.8") override def randkey[A](implicit parse: Parse[A]): Option[A] = randomkey[A] // RANDOMKEY // return a randomly selected key from the currently selected DB. - override def randomkey[A](implicit parse: Parse[A]): Option[A] = ??? + override def randomkey[A](implicit parse: Parse[A]): Option[A] = { + // TODO: make random after implementing 'scan' + currentDB.keys.headOption.map(_.k.parse(parse)) + } // RENAME (oldkey, newkey) // atomically renames the key oldkey to newkey. - override def rename(oldkey: Any, newkey: Any)(implicit format: Format): Boolean = ??? + override def rename(oldkey: Any, newkey: Any)(implicit format: Format): Boolean = + currentDB.renameKey(Key(oldkey), Key(newkey)) whenFalse { throw new RuntimeException("ERR no such key") } // RENAMENX (oldkey, newkey) // rename oldkey into newkey but fails if the destination key newkey already exists. - override def renamenx(oldkey: Any, newkey: Any)(implicit format: Format): Boolean = ??? + override def renamenx(oldkey: Any, newkey: Any)(implicit format: Format): Boolean = + !exists(newkey) && rename(oldkey, newkey) // DBSIZE // return the size of the db. @@ -59,47 +72,46 @@ trait MockOperations extends Operations with Storage { self: Redis => // DELETE (key1 key2 ..) // deletes the specified keys. override def del(key: Any, keys: Any*)(implicit format: Format): Option[Long] = { - val ks = Seq(key) ++ keys - ks foreach { k => currentDB.remove(Key(format(k))) } - Some(ks.size) + val oldSize = currentDB.size + (Seq(key) ++ keys) foreach { k => currentDB.remove(Key(format(k))) } + Some(oldSize - currentDB.size) } // TYPE (key) // return the type of the value stored at key in form of a string. - override def getType(key: Any)(implicit format: Format): Option[String] = ??? + override def getType(key: Any)(implicit format: Format): Option[String] = + Some(currentDB.get(Key(key)).map(_.valueType.toString.toLowerCase).getOrElse("none")) // EXPIRE (key, expiry) // sets the expire time (in sec.) for the specified key. - override def expire(key: Any, ttl: Int)(implicit format: Format): Boolean = ??? + override def expire(key: Any, ttl: Int)(implicit format: Format): Boolean = pexpire(key, ttl * 1000) // PEXPIRE (key, expiry) // sets the expire time (in milli sec.) for the specified key. - override def pexpire(key: Any, ttlInMillis: Int)(implicit format: Format): Boolean = ??? + override def pexpire(key: Any, ttlInMillis: Int)(implicit format: Format): Boolean = + currentDB.updateTTL(Key(key), ttlInMillis) // EXPIREAT (key, unix timestamp) // sets the expire time for the specified key. - override def expireat(key: Any, timestamp: Long)(implicit format: Format): Boolean = ??? + override def expireat(key: Any, timestamp: Long)(implicit format: Format): Boolean = pexpireat(key, timestamp * 1000L) // PEXPIREAT (key, unix timestamp) // sets the expire timestamp in millis for the specified key. - override def pexpireat(key: Any, timestampInMillis: Long)(implicit format: Format): Boolean = ??? + override def pexpireat(key: Any, timestampInMillis: Long)(implicit format: Format): Boolean = + currentDB.updateExpireAt(Key(key), timestampInMillis) // TTL (key) // returns the remaining time to live of a key that has a timeout - override def ttl(key: Any)(implicit format: Format): Option[Long] = ??? + override def ttl(key: Any)(implicit format: Format): Option[Long] = + pttl(key).map(x => if (x < 0L) -1L else math.round(x / 1000.0)) // PTTL (key) // returns the remaining time to live of a key that has a timeout in millis - override def pttl(key: Any)(implicit format: Format): Option[Long] = ??? + override def pttl(key: Any)(implicit format: Format): Option[Long] = currentDB.getTTL(Key(key)) // SELECT (index) // selects the DB to connect, defaults to 0 (zero). - override def select(index: Int): Boolean = if (0 <= index) { - db = index - true - } else { - false - } + override def select(index: Int): Boolean = (0 <= index) whenTrue { db = index } // FLUSHDB the DB // removes all the DB data. @@ -117,7 +129,23 @@ trait MockOperations extends Operations with Storage { self: Redis => // MOVE // Move the specified key from the currently selected DB to the specified destination DB. - override def move(key: Any, db: Int)(implicit format: Format): Boolean = ??? + override def move(key: Any, db: Int)(implicit format: Format): Boolean = if (this.db == db) { + true + } else if (db < 0) { + false + } else { + currentDB.synchronized { + val dst = getDB(db) + dst.synchronized { + val k = Key(key) + currentDB.get(k).map { v => + dst.update(k, v) + currentDB.getExpireAt(k) map { dst.updateExpireAt(k, _) } getOrElse dst.removeExpireAt(k) + currentDB.remove(k) + }.isDefined + } + } + } // QUIT // exits the server. @@ -125,12 +153,15 @@ trait MockOperations extends Operations with Storage { self: Redis => // AUTH // auths with the server. - override def auth(secret: Any)(implicit format: Format): Boolean = ??? + override def auth(secret: Any)(implicit format: Format): Boolean = { + // always returns true in the mock + true + } // PERSIST (key) // Remove the existing timeout on key, turning the key from volatile (a key with an expire set) // to persistent (a key that will never expire as no timeout is associated). - override def persist(key: Any)(implicit format: Format): Boolean = ??? + override def persist(key: Any)(implicit format: Format): Boolean = currentDB.removeExpireAt(Key(key)) // SCAN // Incrementally iterate the keys space (since 2.8) diff --git a/src/main/scala/com/github/mogproject/redismock/entity/Key.scala b/src/main/scala/com/github/mogproject/redismock/entity/Key.scala index 7d77547..6a738cf 100644 --- a/src/main/scala/com/github/mogproject/redismock/entity/Key.scala +++ b/src/main/scala/com/github/mogproject/redismock/entity/Key.scala @@ -6,11 +6,11 @@ import com.redis.serialization.Format * key data should be stored as vector to calculate accurate hash code * @param k key binary */ -case class Key(k: Vector[Byte]) { +case class Key(k: Bytes) { } object Key { - def apply(k: Array[Byte]) = new Key(k.toVector) + def apply(k: Array[Byte]) = new Key(Bytes(k)) - def apply(k: Any)(implicit format: Format): Key = apply(format(k)) + def apply(k: Any)(implicit format: Format): Key = new Key(Bytes(k)) } \ No newline at end of file diff --git a/src/main/scala/com/github/mogproject/redismock/storage/Storage.scala b/src/main/scala/com/github/mogproject/redismock/storage/Storage.scala index 2faff02..a749d0b 100644 --- a/src/main/scala/com/github/mogproject/redismock/storage/Storage.scala +++ b/src/main/scala/com/github/mogproject/redismock/storage/Storage.scala @@ -15,6 +15,11 @@ trait Storage { def currentDB: Database = currentNode.getOrElseUpdate(db, new Database) def currentNode: Node = Storage.index.getOrElseUpdate((host, port), new Node) + + /** + * get specified database in the current node + */ + def getDB(db: Int): Database = currentNode.getOrElseUpdate(db, new Database) } object Storage { diff --git a/src/main/scala/com/github/mogproject/redismock/util/EnhancedSeq.scala b/src/main/scala/com/github/mogproject/redismock/util/EnhancedSeq.scala index 0adc317..68d0ea4 100644 --- a/src/main/scala/com/github/mogproject/redismock/util/EnhancedSeq.scala +++ b/src/main/scala/com/github/mogproject/redismock/util/EnhancedSeq.scala @@ -2,7 +2,6 @@ package com.github.mogproject.redismock.util import scala.util.Try -//implicit def seqToEnhancedSeq /** * Enhanced and safer indexed sequence class diff --git a/src/main/scala/com/github/mogproject/redismock/util/StringUtil.scala b/src/main/scala/com/github/mogproject/redismock/util/StringUtil.scala new file mode 100644 index 0000000..a461436 --- /dev/null +++ b/src/main/scala/com/github/mogproject/redismock/util/StringUtil.scala @@ -0,0 +1,25 @@ +package com.github.mogproject.redismock.util + + +object StringUtil { + /** + * convert glob-style string to regex string + * @param glob glob-style string + * @return regex string (not Regex instance) + */ + def globToRegex(glob: String): String = { + val b = new StringBuilder + glob.foreach { + case c if "\\/$^+.()=!|.,".contains(c) => + b += '\\' + b += c + case '?' => + b += '.' + case '*' => + b ++= ".*" + case c => + b += c + } + b.result() + } +} diff --git a/src/main/scala/com/github/mogproject/redismock/util/TTLTrieMap.scala b/src/main/scala/com/github/mogproject/redismock/util/TTLTrieMap.scala index cfaeda7..0493392 100644 --- a/src/main/scala/com/github/mogproject/redismock/util/TTLTrieMap.scala +++ b/src/main/scala/com/github/mogproject/redismock/util/TTLTrieMap.scala @@ -1,6 +1,7 @@ package com.github.mogproject.redismock.util import scala.collection.concurrent.TrieMap +import com.github.mogproject.redismock.util.ops._ /** * Thread-safe key-value store with the time-to-live attribute @@ -36,6 +37,25 @@ class TTLTrieMap[K, V] { } } + /** + * update datetime to be expired for the specified key + * @param key key + * @param timestamp timestamp to be expired in millis + * @return true if the key exists + */ + def updateExpireAt(key: K, timestamp: Long): Boolean = withTransaction { t => + t.contains(key) whenTrue t.expireAt.update(key, timestamp) + } + + /** + * update key's ttl + * + * @param key key + * @param ttl time to live in millis + * @return true if the key exists + */ + def updateTTL(key: K, ttl: Long): Boolean = updateExpireAt(key, now() + ttl) + /** * get the current value from the key * @@ -52,10 +72,54 @@ class TTLTrieMap[K, V] { } } + /** + * get the datetime to be expired in millis + * + * @param key key + * @return if the key exists and the ttl is not set, then return Some(-1) + */ + def getExpireAt(key: K): Option[Long] = + withTruncate(){ if (contains(key)) Some(expireAt.getOrElse(key, -1L)) else None } + + /** + * get time to live + * + * @param key key + * @return time to live in millis wrapped by Option + */ + def getTTL(key: K): Option[Long] = getExpireAt(key).map(_ - now()) + + /** + * rename the key + * + * @param oldKey old key + * @param newKey new key + * @return true when the old key exists + */ + def renameKey(oldKey: K, newKey: K): Boolean = synchronized { + get(oldKey) match { + case Some(v) => + store.update(newKey, v) + expireAt.get(oldKey) foreach { t => + expireAt.update(newKey, t) + expireAt.remove(oldKey) + } + store.remove(oldKey) + true + case None => + false + } + } + + /** + * get the lazy list of all keys + */ + def keys: Iterable[K] = store.keys + /** * clear all the keys/values */ - def clear(): Unit = { + def clear(): Unit = synchronized { store.clear() expireAt.clear() } @@ -75,11 +139,23 @@ class TTLTrieMap[K, V] { * remove specified key with value * @param key key */ - def remove(key: K): Unit = { + def remove(key: K): Unit = synchronized { store.remove(key) expireAt.remove(key) } + /** + * remove the timestamp to be expired + * This means the key will be alive forever. + * + * @param key key + * @return true if the key exists + */ + def removeExpireAt(key: K): Boolean = withTruncate(){ + expireAt.remove(key) + contains(key) + } + private def truncate(time: Long): Unit = { expireAt.foreach { case (k, t) if t <= time => remove(k) @@ -87,8 +163,12 @@ class TTLTrieMap[K, V] { } } - private[util] def withTruncate[A](time: Long = now())(f: => A): A = { truncate(time); f } + private[util] def withTruncate[A](time: Long = now())(f: => A): A = synchronized { + truncate(time) + f + } + def withTransaction[A](thunk: TTLTrieMap[K, V] => A): A = synchronized(thunk(this)) override def equals(other: Any): Boolean = other match { case that: TTLTrieMap[K, V] => withTruncate()(this.store == that.store && this.expireAt == that.expireAt) diff --git a/src/main/scala/com/github/mogproject/redismock/util/ops/package.scala b/src/main/scala/com/github/mogproject/redismock/util/ops/package.scala new file mode 100644 index 0000000..1700c4b --- /dev/null +++ b/src/main/scala/com/github/mogproject/redismock/util/ops/package.scala @@ -0,0 +1,37 @@ +package com.github.mogproject.redismock.util + +package object ops { + + implicit class IdOps[A](val self: A) extends AnyVal { + /** + * pipeline processing + * @param f function + * @tparam B return type + * @return + */ + final def |>[B](f: A => B): B = f(self) + + /** + * do the side effect with the value, then return the original + * @param f function with side effect + * @return + */ + final def <|(f: A => Any): A = { + f(self) + self + } + } + + implicit class BooleanOps(val self: Boolean) extends AnyVal { + final def whenTrue(f: => Any): Boolean = self <| { + case true => f + case false => + } + + final def whenFalse(f: => Any): Boolean = self <| { + case true => + case false => f + } + } + +} diff --git a/src/test/scala/com/github/mogproject/redismock/MockOperationsSpec.scala b/src/test/scala/com/github/mogproject/redismock/MockOperationsSpec.scala new file mode 100644 index 0000000..c528520 --- /dev/null +++ b/src/test/scala/com/github/mogproject/redismock/MockOperationsSpec.scala @@ -0,0 +1,188 @@ +package com.github.mogproject.redismock + +import org.scalatest.FunSpec +import org.scalatest.BeforeAndAfterEach +import org.scalatest.BeforeAndAfterAll +import org.scalatest.Matchers + + +class MockOperationsSpec extends FunSpec +with Matchers +with BeforeAndAfterEach +with BeforeAndAfterAll { + + val r = TestUtil.getRedisClient + + override def beforeEach = { + } + + override def afterEach = { + r.flushdb + } + + override def afterAll = { + r.disconnect + } + + describe("keys") { + it("should fetch keys") { + r.set("anshin-1", "debasish") + r.set("anshin-2", "maulindu") + r.keys("anshin*") match { + case Some(s: List[Option[String]]) => s.size should equal(2) + case None => fail("should have 2 elements") + } + } + + it("should fetch keys with spaces") { + r.set("anshin 1", "debasish") + r.set("anshin 2", "maulindu") + r.keys("anshin*") match { + case Some(s: List[Option[String]]) => s.size should equal(2) + case None => fail("should have 2 elements") + } + } + } + + describe("randomkey") { + it("should give") { + r.set("anshin-1", "debasish") + r.set("anshin-2", "maulindu") + r.randomkey match { + case Some(s: String) => s should startWith("anshin") + case None => fail("should have 2 elements") + } + } + } + + describe("rename") { + it("should give") { + r.set("anshin-1", "debasish") + r.set("anshin-2", "maulindu") + r.rename("anshin-2", "anshin-2-new") should equal(true) + val thrown = the [Exception] thrownBy { r.rename("anshin-2", "anshin-2-new") } + thrown.getMessage should equal ("ERR no such key") + } + } + + describe("renamenx") { + it("should give") { + r.set("anshin-1", "debasish") + r.set("anshin-2", "maulindu") + r.renamenx("anshin-2", "anshin-2-new") should equal(true) + r.renamenx("anshin-1", "anshin-2-new") should equal(false) + } + } + + describe("dbsize") { + it("should give") { + r.set("anshin-1", "debasish") + r.set("anshin-2", "maulindu") + r.dbsize.get should equal(2) + } + } + + describe("exists") { + it("should give") { + r.set("anshin-1", "debasish") + r.set("anshin-2", "maulindu") + r.exists("anshin-2") should equal(true) + r.exists("anshin-1") should equal(true) + r.exists("anshin-3") should equal(false) + } + } + + describe("del") { + it("should give") { + r.set("anshin-1", "debasish") + r.set("anshin-2", "maulindu") + r.del("anshin-2", "anshin-1").get should equal(2) + r.del("anshin-2", "anshin-1").get should equal(0) + } + } + + describe("type") { + it("should give") { + r.set("anshin-1", "debasish") + r.set("anshin-2", "maulindu") + r.getType("anshin-2").get should equal("string") + } + } + + describe("expire") { + it("should give") { + r.set("anshin-1", "debasish") + r.set("anshin-2", "maulindu") + r.expire("anshin-2", 1000) should equal(true) + r.ttl("anshin-2") should equal(Some(1000)) + r.expire("anshin-3", 1000) should equal(false) + } + } + + describe("persist") { + it("should give") { + r.set("key-2", "maulindu") + r.expire("key-2", 1000) should equal(true) + r.ttl("key-2") should equal(Some(1000)) + r.persist("key-2") should equal(true) + r.ttl("key-2") should equal(Some(-1)) + r.persist("key-3") should equal(false) + } + } +/* + describe("sort") { + it("should give") { + // sort[A](key:String, limit:Option[Pair[Int, Int]] = None, desc:Boolean = false, alpha:Boolean = false, by:Option[String] = None, get:List[String] = Nil)(implicit format:Format, parse:Parse[A]):Option[List[Option[A]]] = { + r.hset("hash-1", "description", "one") + r.hset("hash-1", "order", "100") + r.hset("hash-2", "description", "two") + r.hset("hash-2", "order", "25") + r.hset("hash-3", "description", "three") + r.hset("hash-3", "order", "50") + r.sadd("alltest", 1) + r.sadd("alltest", 2) + r.sadd("alltest", 3) + r.sort("alltest").getOrElse(Nil) should equal(List(Some("1"), Some("2"), Some("3"))) + r.sort("alltest", Some((0, 1))).getOrElse(Nil) should equal(List(Some("1"))) + r.sort("alltest", None, true).getOrElse(Nil) should equal(List(Some("3"), Some("2"), Some("1"))) + r.sort("alltest", None, false, false, Some("hash-*->order")).getOrElse(Nil) should equal(List(Some("2"), Some("3"), Some("1"))) + r.sort("alltest", None, false, false, None, List("hash-*->description")).getOrElse(Nil) should equal(List(Some("one"), Some("two"), Some("three"))) + r.sort("alltest", None, false, false, None, List("hash-*->description", "hash-*->order")).getOrElse(Nil) should equal(List(Some("one"), Some("100"), Some("two"), Some("25"), Some("three"), Some("50"))) + } + } + import com.redis.serialization._ + describe("sortNStore") { + it("should give") { + r.sadd("alltest", 10) + r.sadd("alltest", 30) + r.sadd("alltest", 3) + r.sadd("alltest", 1) + + // default serialization : return String + r.sortNStore("alltest", storeAt = "skey").getOrElse(-1) should equal(4) + r.lrange("skey", 0, 10).get should equal(List(Some("1"), Some("3"), Some("10"), Some("30"))) + + // Long serialization : return Long + implicit val parseLong = Parse[Long](new String(_).toLong) + r.sortNStore[Long]("alltest", storeAt = "skey").getOrElse(-1) should equal(4) + r.lrange("skey", 0, 10).get should equal(List(Some(1), Some(3), Some(10), Some(30))) + } + }*/ + + // + // additional tests + // + describe("type (additional)") { + it("should give") { + r.lpush("list-1", "foo", "bar") + r.sadd("set-1", 10) + r.hset("hash-1", "description", "one") + + r.getType("list-x") shouldBe Some("none") + r.getType("list-1") shouldBe Some("list") + r.getType("set-1") shouldBe Some("set") + r.getType("hash-1") shouldBe Some("hash") + } + + } +}