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