+module('Redis', package.seeall)
+
+require('socket') -- requires LuaSocket as a dependency
+
+-- ############################################################################
+
+local protocol = {
+ newline = '\r\n', ok = 'OK', err = 'ERR', null = 'nil',
+}
+
+-- ############################################################################
+
+local function toboolean(value)
+ return value == 1
+end
+
+local function _write(self, buffer)
+ local _, err = self.socket:send(buffer)
+ if err then error(err) end
+end
+
+local function _read(self, len)
+ if len == nil then len = '*l' end
+ local line, err = self.socket:receive(len)
+ if not err then return line else error('Connection error: ' .. err) end
+end
+
+-- ############################################################################
+
+local function _read_response(self)
+ if options and options.close == true then return end
+
+ local res = _read(self)
+ local prefix = res:sub(1, -#res)
+ local response_handler = protocol.prefixes[prefix]
+
+ if not response_handler then
+ error("Unknown response prefix: " .. prefix)
+ else
+ return response_handler(self, res)
+ end
+end
+
+
+local function _send_raw(self, buffer)
+ -- TODO: optimize
+ local bufferType = type(buffer)
+
+ if bufferType == 'string' then
+ _write(self, buffer)
+ elseif bufferType == 'table' then
+ _write(self, table.concat(buffer))
+ else
+ error('Argument error: ' .. bufferType)
+ end
+
+ return _read_response(self)
+end
+
+local function _send_inline(self, command, ...)
+ if arg.n == 0 then
+ _write(self, command .. protocol.newline)
+ else
+ local arguments = arg
+ arguments.n = nil
+
+ if #arguments > 0 then
+ arguments = table.concat(arguments, ' ')
+ else
+ arguments = ''
+ end
+
+ _write(self, command .. ' ' .. arguments .. protocol.newline)
+ end
+
+ return _read_response(self)
+end
+
+local function _send_bulk(self, command, ...)
+ local arguments = arg
+ local data = tostring(table.remove(arguments))
+ arguments.n = nil
+
+ -- TODO: optimize
+ if #arguments > 0 then
+ arguments = table.concat(arguments, ' ')
+ else
+ arguments = ''
+ end
+
+ return _send_raw(self, {
+ command, ' ', arguments, ' ', #data, protocol.newline, data, protocol.newline
+ })
+end
+
+
+local function _read_line(self, response)
+ return response:sub(2)
+end
+
+local function _read_error(self, response)
+ local err_line = response:sub(2)
+
+ if err_line:sub(1, 3) == protocol.err then
+ error("Redis error: " .. err_line:sub(5))
+ else
+ error("Redis error: " .. err_line)
+ end
+end
+
+local function _read_bulk(self, response)
+ local str = response:sub(2)
+ local len = tonumber(str)
+
+ if not len then
+ error('Cannot parse ' .. str .. ' as data length.')
+ else
+ if len == -1 then return nil end
+ local data = _read(self, len + 2)
+ return data:sub(1, -3);
+ end
+end
+
+local function _read_multibulk(self, response)
+ local str = response:sub(2)
+
+ -- TODO: add a check if the returned value is indeed a number
+ local list_count = tonumber(str)
+
+ if list_count == -1 then
+ return nil
+ else
+ local list = {}
+
+ if list_count > 0 then
+ for i = 1, list_count do
+ table.insert(list, i, _read_bulk(self, _read(self)))
+ end
+ end
+
+ return list
+ end
+end
+
+local function _read_integer(self, response)
+ local res = response:sub(2)
+ local number = tonumber(res)
+
+ if not number then
+ if res == protocol.null then
+ return nil
+ else
+ error('Cannot parse ' .. res .. ' as numeric response.')
+ end
+ end
+
+ return number
+end
+
+-- ############################################################################
+
+protocol.prefixes = {
+ ['+'] = _read_line,
+ ['-'] = _read_error,
+ ['$'] = _read_bulk,
+ ['*'] = _read_multibulk,
+ [':'] = _read_integer,
+}
+
+-- ############################################################################
+
+local methods = {
+ -- miscellaneous commands
+ ping = {
+ 'PING', _send_inline, function(response)
+ if response == 'PONG' then return true else return false end
+ end
+ },
+ echo = { 'ECHO', _send_bulk },
+ -- TODO: the server returns an empty -ERR on authentication failure
+ auth = { 'AUTH' },
+
+ -- connection handling
+ quit = { 'QUIT', function(self, command)
+ _write(self, command .. protocol.newline)
+ end
+ },
+
+ -- commands operating on string values
+ set = { 'SET', _send_bulk },
+ set_preserve = { 'SETNX', _send_bulk, toboolean },
+ get = { 'GET' },
+ get_multiple = { 'MGET' },
+ increment = { 'INCR' },
+ increment_by = { 'INCRBY' },
+ decrement = { 'DECR' },
+ decrement_by = { 'DECRBY' },
+ exists = { 'EXISTS', _send_inline, toboolean },
+ delete = { 'DEL', _send_inline, toboolean },
+ type = { 'TYPE' },
+
+ -- commands operating on the key space
+ keys = {
+ 'KEYS', _send_inline, function(response)
+ local keys = {}
+ response:gsub('%w+', function(key)
+ table.insert(keys, key)
+ end)
+ return keys
+ end
+ },
+ random_key = { 'RANDOMKEY' },
+ rename = { 'RENAME' },
+ rename_preserve = { 'RENAMENX' },
+ database_size = { 'DBSIZE' },
+
+ -- commands operating on lists
+ push_tail = { 'RPUSH', _send_bulk },
+ push_head = { 'LPUSH', _send_bulk },
+ list_length = { 'LLEN', _send_inline, function(response, key)
+ --[[ TODO: redis seems to return a -ERR when the specified key does
+ not hold a list value, but this behaviour is not
+ consistent with the specs docs. This might be due to the
+ -ERR response paradigm being new, which supersedes the
+ check for negative numbers to identify errors. ]]
+ if response == -2 then
+ error('Key ' .. key .. ' does not hold a list value')
+ end
+ return response
+ end
+ },
+ list_range = { 'LRANGE' },
+ list_trim = { 'LTRIM' },
+ list_index = { 'LINDEX' },
+ list_set = { 'LSET', _send_bulk },
+ list_remove = { 'LREM', _send_bulk },
+ pop_first = { 'LPOP' },
+ pop_last = { 'RPOP' },
+
+ -- commands operating on sets
+ set_add = { 'SADD' },
+ set_remove = { 'SREM' },
+ set_cardinality = { 'SCARD' },
+ set_is_member = { 'SISMEMBER' },
+ set_intersection = { 'SINTER' },
+ set_intersection_store = { 'SINTERSTORE' },
+ set_members = { 'SMEMBERS' },
+
+ -- multiple databases handling commands
+ select_database = { 'SELECT' },
+ move_key = { 'MOVE' },
+ flush_database = { 'FLUSHDB' },
+ flush_databases = { 'FLUSHALL' },
+
+ -- sorting
+ --[[
+ TODO: should we pass sort parameters as a table? e.g:
+ params = {
+ by = 'weight_*',
+ get = 'object_*',
+ limit = { 0, 10 },
+ sort = { 'desc', 'alpha' }
+ }
+ --]]
+ sort = { 'SORT' },
+
+ -- persistence control commands
+ save = { 'SAVE' },
+ background_save = { 'BGSAVE' },
+ last_save = { 'LASTSAVE' },
+ shutdown = { 'SHUTDOWN', function(self, command)
+ _write(self, command .. protocol.newline)
+ end
+ },
+
+ -- remote server control commands
+ info = {
+ 'INFO', _send_inline, function(response)
+ local info = {}
+ response:gsub('([^\r\n]*)\r\n', function(kv)
+ local k,v = kv:match(('([^:]*):([^:]*)'):rep(1))
+ info[k] = v
+ end)
+ return info
+ end
+ },
+}
+
+function connect(host, port)
+ local client_socket = socket.connect(host, port)
+
+ if not client_socket then
+ error('Could not connect to ' .. host .. ':' .. port)
+ end
+
+ local redis_client = {
+ socket = client_socket,
+ raw_cmd = function(self, buffer)
+ return _send_raw(self, buffer .. protocol.newline)
+ end,
+ }
+
+ return setmetatable(redis_client, {
+ __index = function(self, method)
+ local redis_meth = methods[method]
+ if redis_meth then
+ return function(self, ...)
+ if not redis_meth[2] then
+ table.insert(redis_meth, 2, _send_inline)
+ end
+
+ local response = redis_meth[2](self, redis_meth[1], ...)
+ if redis_meth[3] then
+ return redis_meth[3](response, ...)
+ else
+ return response
+ end
+ end
+ end
+ end
+ })
+end