Permalink
Cannot retrieve contributors at this time
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
ZeroBranePackage/redis.lua
Go to fileThis commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
1984 lines (1762 sloc)
67.6 KB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- Copyright 2015 Paul Kulchenko, ZeroBrane LLC; All rights reserved | |
-- Integration with Redis data store (http://redis.io) from Redis Labs (https://redislabs.com). | |
-- Repository of the redis-lua Redis client: https://github.com/nrk/redis-lua | |
local redis = (function() -- redis-lua module start ---------------------------- | |
local redis = { | |
_VERSION = 'redis-lua 2.0.5-dev', | |
_DESCRIPTION = 'A Lua client library for the redis key value storage system.', | |
_COPYRIGHT = 'Copyright (C) 2009-2012 Daniele Alessandri', | |
} | |
local unpack = _G.unpack or table.unpack | |
local network, request, response = {}, {}, {} | |
local defaults = { | |
host = '127.0.0.1', | |
port = 6379, | |
tcp_nodelay = true, | |
path = nil, | |
} | |
local function merge_defaults(parameters) | |
if parameters == nil then | |
parameters = {} | |
end | |
for k, v in pairs(defaults) do | |
if parameters[k] == nil then | |
parameters[k] = defaults[k] | |
end | |
end | |
return parameters | |
end | |
local function parse_boolean(v) | |
if v == '1' or v == 'true' or v == 'TRUE' then | |
return true | |
elseif v == '0' or v == 'false' or v == 'FALSE' then | |
return false | |
else | |
return nil | |
end | |
end | |
local function toboolean(value) return value == 1 end | |
local function sort_request(client, command, key, params) | |
--[[ params = { | |
by = 'weight_*', | |
get = 'object_*', | |
limit = { 0, 10 }, | |
sort = 'desc', | |
alpha = true, | |
} ]] | |
local query = { key } | |
if params then | |
if params.by then | |
table.insert(query, 'BY') | |
table.insert(query, params.by) | |
end | |
if type(params.limit) == 'table' then | |
-- TODO: check for lower and upper limits | |
table.insert(query, 'LIMIT') | |
table.insert(query, params.limit[1]) | |
table.insert(query, params.limit[2]) | |
end | |
if params.get then | |
if (type(params.get) == 'table') then | |
for _, getarg in pairs(params.get) do | |
table.insert(query, 'GET') | |
table.insert(query, getarg) | |
end | |
else | |
table.insert(query, 'GET') | |
table.insert(query, params.get) | |
end | |
end | |
if params.sort then | |
table.insert(query, params.sort) | |
end | |
if params.alpha == true then | |
table.insert(query, 'ALPHA') | |
end | |
if params.store then | |
table.insert(query, 'STORE') | |
table.insert(query, params.store) | |
end | |
end | |
request.multibulk(client, command, query) | |
end | |
local function zset_range_request(client, command, ...) | |
local args, opts = {...}, { } | |
if #args >= 1 and type(args[#args]) == 'table' then | |
local options = table.remove(args, #args) | |
if options.withscores then | |
table.insert(opts, 'WITHSCORES') | |
end | |
end | |
for _, v in pairs(opts) do table.insert(args, v) end | |
request.multibulk(client, command, args) | |
end | |
local function zset_range_byscore_request(client, command, ...) | |
local args, opts = {...}, { } | |
if #args >= 1 and type(args[#args]) == 'table' then | |
local options = table.remove(args, #args) | |
if options.limit then | |
table.insert(opts, 'LIMIT') | |
table.insert(opts, options.limit.offset or options.limit[1]) | |
table.insert(opts, options.limit.count or options.limit[2]) | |
end | |
if options.withscores then | |
table.insert(opts, 'WITHSCORES') | |
end | |
end | |
for _, v in pairs(opts) do table.insert(args, v) end | |
request.multibulk(client, command, args) | |
end | |
local function zset_range_reply(reply, command, ...) | |
local args = {...} | |
local opts = args[4] | |
if opts and (opts.withscores or string.lower(tostring(opts)) == 'withscores') then | |
local new_reply = { } | |
for i = 1, #reply, 2 do | |
table.insert(new_reply, { reply[i], reply[i + 1] }) | |
end | |
return new_reply | |
else | |
return reply | |
end | |
end | |
local function zset_store_request(client, command, ...) | |
local args, opts = {...}, { } | |
if #args >= 1 and type(args[#args]) == 'table' then | |
local options = table.remove(args, #args) | |
if options.weights and type(options.weights) == 'table' then | |
table.insert(opts, 'WEIGHTS') | |
for _, weight in ipairs(options.weights) do | |
table.insert(opts, weight) | |
end | |
end | |
if options.aggregate then | |
table.insert(opts, 'AGGREGATE') | |
table.insert(opts, options.aggregate) | |
end | |
end | |
for _, v in pairs(opts) do table.insert(args, v) end | |
request.multibulk(client, command, args) | |
end | |
local function mset_filter_args(client, command, ...) | |
local args, arguments = {...}, {} | |
if (#args == 1 and type(args[1]) == 'table') then | |
for k,v in pairs(args[1]) do | |
table.insert(arguments, k) | |
table.insert(arguments, v) | |
end | |
else | |
arguments = args | |
end | |
request.multibulk(client, command, arguments) | |
end | |
local function hash_multi_request_builder(builder_callback) | |
return function(client, command, ...) | |
local args, arguments = {...}, { } | |
if #args == 2 then | |
table.insert(arguments, args[1]) | |
for k, v in pairs(args[2]) do | |
builder_callback(arguments, k, v) | |
end | |
else | |
arguments = args | |
end | |
request.multibulk(client, command, arguments) | |
end | |
end | |
local function parse_info(response) | |
local info = {} | |
local current = info | |
response:gsub('([^\r\n]*)\r\n', function(kv) | |
if kv == '' then return end | |
local section = kv:match('^# (%w+)$') | |
if section then | |
current = {} | |
info[section:lower()] = current | |
return | |
end | |
local k,v = kv:match(('([^:]*):([^:]*)'):rep(1)) | |
if k:match('db%d+') then | |
current[k] = {} | |
v:gsub(',', function(dbkv) | |
local dbk,dbv = dbkv:match('([^:]*)=([^:]*)') | |
current[k][dbk] = dbv | |
end) | |
else | |
current[k] = v | |
end | |
end) | |
return info | |
end | |
local function scan_request(client, command, ...) | |
local args, req, params = {...}, { }, nil | |
if command == 'SCAN' then | |
table.insert(req, args[1]) | |
params = args[2] | |
else | |
table.insert(req, args[1]) | |
table.insert(req, args[2]) | |
params = args[3] | |
end | |
if params and params.match then | |
table.insert(req, 'MATCH') | |
table.insert(req, params.match) | |
end | |
if params and params.count then | |
table.insert(req, 'COUNT') | |
table.insert(req, params.count) | |
end | |
request.multibulk(client, command, req) | |
end | |
local zscan_response = function(reply, command, ...) | |
local original, new = reply[2], { } | |
for i = 1, #original, 2 do | |
table.insert(new, { original[i], tonumber(original[i + 1]) }) | |
end | |
reply[2] = new | |
return reply | |
end | |
local hscan_response = function(reply, command, ...) | |
local original, new = reply[2], { } | |
for i = 1, #original, 2 do | |
new[original[i]] = original[i + 1] | |
end | |
reply[2] = new | |
return reply | |
end | |
local function load_methods(proto, commands) | |
local client = setmetatable ({}, getmetatable(proto)) | |
for cmd, fn in pairs(commands) do | |
if type(fn) ~= 'function' then | |
redis.error('invalid type for command ' .. cmd .. '(must be a function)') | |
end | |
client[cmd] = fn | |
end | |
for i, v in pairs(proto) do | |
client[i] = v | |
end | |
return client | |
end | |
local function create_client(proto, client_socket, commands) | |
local client = load_methods(proto, commands) | |
client.error = redis.error | |
client.network = { | |
socket = client_socket, | |
read = network.read, | |
write = network.write, | |
} | |
client.requests = { | |
multibulk = request.multibulk, | |
} | |
return client | |
end | |
-- ############################################################################ | |
function network.write(client, buffer) | |
local _, err = client.network.socket:send(buffer) | |
if type(client.onwrite) == 'function' then client:onwrite(buffer, err) end | |
if err then return client.error(err) end | |
end | |
function network.read(client, len) | |
if len == nil then len = '*l' end | |
local line, err = client.network.socket:receive(len) | |
if type(client.onread) == 'function' then client:onread(line, err) end | |
if not err then return line else return client.error(err) end | |
end | |
-- ############################################################################ | |
function response.read(client) | |
local payload, err = client.network.read(client) | |
if not payload then return client.error('reading error: ' .. err) end | |
local prefix, data = payload:sub(1, -#payload), payload:sub(2) | |
-- status reply | |
if prefix == '+' then | |
if data == 'OK' then | |
return true | |
elseif data == 'QUEUED' then | |
return { queued = true } | |
else | |
return data | |
end | |
-- error reply | |
elseif prefix == '-' then | |
return client.error('redis error: ' .. data) | |
-- integer reply | |
elseif prefix == ':' then | |
local number = tonumber(data) | |
if not number then | |
if data == 'nil' then | |
return nil | |
end | |
return client.error('cannot parse ' .. data .. ' as a numeric response.') | |
end | |
return number | |
-- bulk reply | |
elseif prefix == '$' then | |
local length = tonumber(data) | |
if not length then | |
return client.error('cannot parse ' .. length .. ' as data length') | |
end | |
if length == -1 then | |
return nil | |
end | |
local nextchunk = client.network.read(client, length + 2) | |
return nextchunk:sub(1, -3) | |
-- multibulk reply | |
elseif prefix == '*' then | |
local count = tonumber(data) | |
if count == -1 then | |
return nil | |
end | |
local list = {} | |
if count > 0 then | |
local reader = response.read | |
for i = 1, count do | |
list[i] = reader(client) | |
end | |
end | |
return list | |
-- unknown type of reply | |
else | |
return client.error('unknown response prefix: ' .. prefix) | |
end | |
end | |
-- ############################################################################ | |
function request.raw(client, buffer) | |
local bufferType = type(buffer) | |
if bufferType == 'table' then | |
client.network.write(client, table.concat(buffer)) | |
elseif bufferType == 'string' then | |
client.network.write(client, buffer) | |
else | |
client.error('argument error: ' .. bufferType) | |
end | |
end | |
function request.multibulk(client, command, ...) | |
local args = {...} | |
local argsn = #args | |
local buffer = { true, true } | |
if argsn == 1 and type(args[1]) == 'table' then | |
argsn, args = #args[1], args[1] | |
end | |
buffer[1] = '*' .. tostring(argsn + 1) .. "\r\n" | |
buffer[2] = '$' .. #command .. "\r\n" .. command .. "\r\n" | |
local table_insert = table.insert | |
for i = 1, argsn do | |
local s_argument = tostring(args[i] or '') | |
table_insert(buffer, '$' .. #s_argument .. "\r\n" .. s_argument .. "\r\n") | |
end | |
client.network.write(client, table.concat(buffer)) | |
end | |
-- ############################################################################ | |
local function custom(command, send, parse) | |
command = string.upper(command) | |
return function(client, ...) | |
send(client, command, ...) | |
local reply, err = response.read(client) | |
if type(reply) == 'table' and reply.queued then | |
reply.parser = parse | |
return reply | |
elseif reply == nil then | |
return reply, err | |
else | |
if parse then | |
return parse(reply, command, ...) | |
end | |
return reply | |
end | |
end | |
end | |
local function command(command, opts) | |
if opts == nil or type(opts) == 'function' then | |
return custom(command, request.multibulk, opts) | |
else | |
return custom(command, opts.request or request.multibulk, opts.response) | |
end | |
end | |
local define_command_impl = function(target, name, opts) | |
local opts = opts or {} | |
target[string.lower(name)] = custom( | |
opts.command or string.upper(name), | |
opts.request or request.multibulk, | |
opts.response or nil | |
) | |
end | |
local undefine_command_impl = function(target, name) | |
target[string.lower(name)] = nil | |
end | |
-- ############################################################################ | |
local client_prototype = {} | |
client_prototype.raw_cmd = function(client, buffer) | |
request.raw(client, buffer .. "\r\n") | |
return response.read(client) | |
end | |
-- obsolete | |
client_prototype.define_command = function(client, name, opts) | |
define_command_impl(client, name, opts) | |
end | |
-- obsolete | |
client_prototype.undefine_command = function(client, name) | |
undefine_command_impl(client, name) | |
end | |
client_prototype.quit = function(client) | |
request.multibulk(client, 'QUIT') | |
client.network.socket:shutdown() | |
return true | |
end | |
client_prototype.shutdown = function(client) | |
request.multibulk(client, 'SHUTDOWN') | |
client.network.socket:shutdown() | |
end | |
-- Command pipelining | |
client_prototype.pipeline = function(client, block) | |
local requests, replies, parsers = {}, {}, {} | |
local table_insert = table.insert | |
local socket_write, socket_read = client.network.write, client.network.read | |
client.network.write = function(_, buffer) | |
table_insert(requests, buffer) | |
end | |
-- TODO: this hack is necessary to temporarily reuse the current | |
-- request -> response handling implementation of redis-lua | |
-- without further changes in the code, but it will surely | |
-- disappear when the new command-definition infrastructure | |
-- will finally be in place. | |
client.network.read = function() return '+QUEUED' end | |
local pipeline = setmetatable({}, { | |
__index = function(env, name) | |
local cmd = client[name] | |
if not cmd then | |
client.error('unknown redis command: ' .. name, 2) | |
end | |
return function(self, ...) | |
local reply = cmd(client, ...) | |
table_insert(parsers, #requests, reply.parser) | |
return reply | |
end | |
end | |
}) | |
local success, retval = pcall(block, pipeline) | |
client.network.write, client.network.read = socket_write, socket_read | |
if not success then return client.error(retval, 0) end | |
client.network.write(client, table.concat(requests, '')) | |
for i = 1, #requests do | |
local reply, parser = response.read(client), parsers[i] | |
if parser then | |
reply = parser(reply) | |
end | |
table_insert(replies, i, reply) | |
end | |
return replies, #requests | |
end | |
-- Publish/Subscribe | |
do | |
local channels = function(channels) | |
if type(channels) == 'string' then | |
channels = { channels } | |
end | |
return channels | |
end | |
local subscribe = function(client, ...) | |
request.multibulk(client, 'subscribe', ...) | |
end | |
local psubscribe = function(client, ...) | |
request.multibulk(client, 'psubscribe', ...) | |
end | |
local unsubscribe = function(client, ...) | |
request.multibulk(client, 'unsubscribe') | |
end | |
local punsubscribe = function(client, ...) | |
request.multibulk(client, 'punsubscribe') | |
end | |
local consumer_loop = function(client) | |
local aborting, subscriptions = false, 0 | |
local abort = function() | |
if not aborting then | |
unsubscribe(client) | |
punsubscribe(client) | |
aborting = true | |
end | |
end | |
return coroutine.wrap(function() | |
while true do | |
local message | |
local response = response.read(client) | |
if response[1] == 'pmessage' then | |
message = { | |
kind = response[1], | |
pattern = response[2], | |
channel = response[3], | |
payload = response[4], | |
} | |
else | |
message = { | |
kind = response[1], | |
channel = response[2], | |
payload = response[3], | |
} | |
end | |
if string.match(message.kind, '^p?subscribe$') then | |
subscriptions = subscriptions + 1 | |
end | |
if string.match(message.kind, '^p?unsubscribe$') then | |
subscriptions = subscriptions - 1 | |
end | |
if aborting and subscriptions == 0 then | |
break | |
end | |
coroutine.yield(message, abort) | |
end | |
end) | |
end | |
client_prototype.pubsub = function(client, subscriptions) | |
if type(subscriptions) == 'table' then | |
if subscriptions.subscribe then | |
subscribe(client, channels(subscriptions.subscribe)) | |
end | |
if subscriptions.psubscribe then | |
psubscribe(client, channels(subscriptions.psubscribe)) | |
end | |
end | |
return consumer_loop(client) | |
end | |
end | |
-- Redis transactions (MULTI/EXEC) | |
do | |
local function identity(...) return ... end | |
local emptytable = {} | |
local function initialize_transaction(client, options, block, queued_parsers) | |
local table_insert = table.insert | |
local coro = coroutine.create(block) | |
if options.watch then | |
local watch_keys = {} | |
for _, key in pairs(options.watch) do | |
table_insert(watch_keys, key) | |
end | |
if #watch_keys > 0 then | |
client:watch(unpack(watch_keys)) | |
end | |
end | |
local transaction_client = setmetatable({}, {__index=client}) | |
transaction_client.exec = function(...) | |
return client.error('cannot use EXEC inside a transaction block') | |
end | |
transaction_client.multi = function(...) | |
coroutine.yield() | |
end | |
transaction_client.commands_queued = function() | |
return #queued_parsers | |
end | |
assert(coroutine.resume(coro, transaction_client)) | |
transaction_client.multi = nil | |
transaction_client.discard = function(...) | |
local reply = client:discard() | |
for i, v in pairs(queued_parsers) do | |
queued_parsers[i]=nil | |
end | |
coro = initialize_transaction(client, options, block, queued_parsers) | |
return reply | |
end | |
transaction_client.watch = function(...) | |
return client.error('WATCH inside MULTI is not allowed') | |
end | |
setmetatable(transaction_client, { __index = function(t, k) | |
local cmd = client[k] | |
if type(cmd) == "function" then | |
local function queuey(self, ...) | |
local reply = cmd(client, ...) | |
assert((reply or emptytable).queued == true, 'a QUEUED reply was expected') | |
table_insert(queued_parsers, reply.parser or identity) | |
return reply | |
end | |
t[k]=queuey | |
return queuey | |
else | |
return cmd | |
end | |
end | |
}) | |
client:multi() | |
return coro | |
end | |
local function transaction(client, options, coroutine_block, attempts) | |
local queued_parsers, replies = {}, {} | |
local retry = tonumber(attempts) or tonumber(options.retry) or 2 | |
local coro = initialize_transaction(client, options, coroutine_block, queued_parsers) | |
local success, retval | |
if coroutine.status(coro) == 'suspended' then | |
success, retval = coroutine.resume(coro) | |
else | |
-- do not fail if the coroutine has not been resumed (missing t:multi() with CAS) | |
success, retval = true, 'empty transaction' | |
end | |
if #queued_parsers == 0 or not success then | |
client:discard() | |
assert(success, retval) | |
return replies, 0 | |
end | |
local raw_replies = client:exec() | |
if not raw_replies then | |
if (retry or 0) <= 0 then | |
return client.error("MULTI/EXEC transaction aborted by the server") | |
else | |
--we're not quite done yet | |
return transaction(client, options, coroutine_block, retry - 1) | |
end | |
end | |
local table_insert = table.insert | |
for i, parser in pairs(queued_parsers) do | |
table_insert(replies, i, parser(raw_replies[i])) | |
end | |
return replies, #queued_parsers | |
end | |
client_prototype.transaction = function(client, arg1, arg2) | |
local options, block | |
if not arg2 then | |
options, block = {}, arg1 | |
elseif arg1 then --and arg2, implicitly | |
options, block = type(arg1)=="table" and arg1 or { arg1 }, arg2 | |
else | |
return client.error("Invalid parameters for redis transaction.") | |
end | |
if not options.watch then | |
local watch_keys = { } | |
for i, v in pairs(options) do | |
if tonumber(i) then | |
table.insert(watch_keys, v) | |
options[i] = nil | |
end | |
end | |
options.watch = watch_keys | |
elseif not (type(options.watch) == 'table') then | |
options.watch = { options.watch } | |
end | |
if not options.cas then | |
local tx_block = block | |
block = function(client, ...) | |
client:multi() | |
return tx_block(client, ...) --can't wrap this in pcall because we're in a coroutine. | |
end | |
end | |
return transaction(client, options, block) | |
end | |
end | |
-- MONITOR context | |
do | |
local monitor_loop = function(client) | |
local monitoring = true | |
-- Tricky since the payload format changed starting from Redis 2.6. | |
local pattern = '^(%d+%.%d+)( ?.- ?) ?"(%a+)" ?(.-)$' | |
local abort = function() | |
monitoring = false | |
end | |
return coroutine.wrap(function() | |
client:monitor() | |
while monitoring do | |
local message, matched | |
local response = response.read(client) | |
local ok = response:gsub(pattern, function(time, info, cmd, args) | |
message = { | |
timestamp = tonumber(time), | |
client = info:match('%d+.%d+.%d+.%d+:%d+'), | |
database = tonumber(info:match('%d+')) or 0, | |
command = cmd, | |
arguments = args:match('.+'), | |
} | |
matched = true | |
end) | |
if not matched then | |
return client.error('Unable to match MONITOR payload: '..response) | |
end | |
coroutine.yield(message, abort) | |
end | |
end) | |
end | |
client_prototype.monitor_messages = function(client) | |
return monitor_loop(client) | |
end | |
end | |
-- ############################################################################ | |
local function connect_tcp(socket, parameters) | |
local host, port = parameters.host, tonumber(parameters.port) | |
if parameters.timeout then | |
socket:settimeout(parameters.timeout, 'b') | |
end | |
local ok, err = socket:connect(host, port) | |
if not ok then | |
redis.error('could not connect to '..host..':'..port..' ['..err..']') | |
end | |
socket:setoption('tcp-nodelay', parameters.tcp_nodelay) | |
return socket | |
end | |
local function connect_unix(socket, parameters) | |
local ok, err = socket:connect(parameters.path) | |
if not ok then | |
redis.error('could not connect to '..parameters.path..' ['..err..']') | |
end | |
return socket | |
end | |
local function create_connection(parameters) | |
if parameters.socket then | |
return parameters.socket | |
end | |
local perform_connection, socket | |
if parameters.scheme == 'unix' then | |
perform_connection, socket = connect_unix, require('socket.unix') | |
assert(socket, 'your build of LuaSocket does not support UNIX domain sockets') | |
else | |
if parameters.scheme then | |
local scheme = parameters.scheme | |
assert(scheme == 'redis' or scheme == 'tcp', 'invalid scheme: '..scheme) | |
end | |
perform_connection, socket = connect_tcp, require('socket').tcp | |
end | |
return perform_connection(socket(), parameters) | |
end | |
-- ############################################################################ | |
function redis.error(message, level) | |
error(message, (level or 1) + 1) | |
end | |
function redis.connect(...) | |
local args, parameters = {...}, nil | |
if #args == 1 then | |
if type(args[1]) == 'table' then | |
parameters = args[1] | |
else | |
local uri = require('socket.url') | |
parameters = uri.parse(select(1, ...)) | |
if parameters.scheme then | |
if parameters.query then | |
for k, v in parameters.query:gmatch('([-_%w]+)=([-_%w]+)') do | |
if k == 'tcp_nodelay' or k == 'tcp-nodelay' then | |
parameters.tcp_nodelay = parse_boolean(v) | |
elseif k == 'timeout' then | |
parameters.timeout = tonumber(v) | |
end | |
end | |
end | |
else | |
parameters.host = parameters.path | |
end | |
end | |
elseif #args > 1 then | |
local host, port, timeout = unpack(args) | |
parameters = { host = host, port = port, timeout = tonumber(timeout) } | |
end | |
local commands = redis.commands or {} | |
if type(commands) ~= 'table' then | |
redis.error('invalid type for the commands table') | |
end | |
local socket = create_connection(merge_defaults(parameters)) | |
local client = create_client(client_prototype, socket, commands) | |
return client | |
end | |
function redis.command(cmd, opts) | |
return command(cmd, opts) | |
end | |
-- obsolete | |
function redis.define_command(name, opts) | |
define_command_impl(redis.commands, name, opts) | |
end | |
-- obsolete | |
function redis.undefine_command(name) | |
undefine_command_impl(redis.commands, name) | |
end | |
-- ############################################################################ | |
-- Commands defined in this table do not take the precedence over | |
-- methods defined in the client prototype table. | |
redis.commands = { | |
-- commands operating on the key space | |
exists = command('EXISTS', { | |
response = toboolean | |
}), | |
del = command('DEL'), | |
type = command('TYPE'), | |
rename = command('RENAME'), | |
renamenx = command('RENAMENX', { | |
response = toboolean | |
}), | |
expire = command('EXPIRE', { | |
response = toboolean | |
}), | |
pexpire = command('PEXPIRE', { -- >= 2.6 | |
response = toboolean | |
}), | |
expireat = command('EXPIREAT', { | |
response = toboolean | |
}), | |
pexpireat = command('PEXPIREAT', { -- >= 2.6 | |
response = toboolean | |
}), | |
ttl = command('TTL'), | |
pttl = command('PTTL'), -- >= 2.6 | |
move = command('MOVE', { | |
response = toboolean | |
}), | |
dbsize = command('DBSIZE'), | |
persist = command('PERSIST', { -- >= 2.2 | |
response = toboolean | |
}), | |
keys = command('KEYS', { | |
response = function(response) | |
if type(response) == 'string' then | |
-- backwards compatibility path for Redis < 2.0 | |
local keys = {} | |
response:gsub('[^%s]+', function(key) | |
table.insert(keys, key) | |
end) | |
response = keys | |
end | |
return response | |
end | |
}), | |
randomkey = command('RANDOMKEY'), | |
sort = command('SORT', { | |
request = sort_request, | |
}), | |
scan = command('SCAN', { -- >= 2.8 | |
request = scan_request, | |
}), | |
-- commands operating on string values | |
set = command('SET'), | |
setnx = command('SETNX', { | |
response = toboolean | |
}), | |
setex = command('SETEX'), -- >= 2.0 | |
psetex = command('PSETEX'), -- >= 2.6 | |
mset = command('MSET', { | |
request = mset_filter_args | |
}), | |
msetnx = command('MSETNX', { | |
request = mset_filter_args, | |
response = toboolean | |
}), | |
get = command('GET'), | |
mget = command('MGET'), | |
getset = command('GETSET'), | |
incr = command('INCR'), | |
incrby = command('INCRBY'), | |
incrbyfloat = command('INCRBYFLOAT', { -- >= 2.6 | |
response = function(reply, command, ...) | |
return tonumber(reply) | |
end, | |
}), | |
decr = command('DECR'), | |
decrby = command('DECRBY'), | |
append = command('APPEND'), -- >= 2.0 | |
substr = command('SUBSTR'), -- >= 2.0 | |
strlen = command('STRLEN'), -- >= 2.2 | |
setrange = command('SETRANGE'), -- >= 2.2 | |
getrange = command('GETRANGE'), -- >= 2.2 | |
setbit = command('SETBIT'), -- >= 2.2 | |
getbit = command('GETBIT'), -- >= 2.2 | |
bitop = command('BITOP'), -- >= 2.6 | |
bitcount = command('BITCOUNT'), -- >= 2.6 | |
-- commands operating on lists | |
rpush = command('RPUSH'), | |
lpush = command('LPUSH'), | |
llen = command('LLEN'), | |
lrange = command('LRANGE'), | |
ltrim = command('LTRIM'), | |
lindex = command('LINDEX'), | |
lset = command('LSET'), | |
lrem = command('LREM'), | |
lpop = command('LPOP'), | |
rpop = command('RPOP'), | |
rpoplpush = command('RPOPLPUSH'), | |
blpop = command('BLPOP'), -- >= 2.0 | |
brpop = command('BRPOP'), -- >= 2.0 | |
rpushx = command('RPUSHX'), -- >= 2.2 | |
lpushx = command('LPUSHX'), -- >= 2.2 | |
linsert = command('LINSERT'), -- >= 2.2 | |
brpoplpush = command('BRPOPLPUSH'), -- >= 2.2 | |
-- commands operating on sets | |
sadd = command('SADD'), | |
srem = command('SREM'), | |
spop = command('SPOP'), | |
smove = command('SMOVE', { | |
response = toboolean | |
}), | |
scard = command('SCARD'), | |
sismember = command('SISMEMBER', { | |
response = toboolean | |
}), | |
sinter = command('SINTER'), | |
sinterstore = command('SINTERSTORE'), | |
sunion = command('SUNION'), | |
sunionstore = command('SUNIONSTORE'), | |
sdiff = command('SDIFF'), | |
sdiffstore = command('SDIFFSTORE'), | |
smembers = command('SMEMBERS'), | |
srandmember = command('SRANDMEMBER'), | |
sscan = command('SSCAN', { -- >= 2.8 | |
request = scan_request, | |
}), | |
-- commands operating on sorted sets | |
zadd = command('ZADD'), | |
zincrby = command('ZINCRBY', { | |
response = function(reply, command, ...) | |
return tonumber(reply) | |
end, | |
}), | |
zrem = command('ZREM'), | |
zrange = command('ZRANGE', { | |
request = zset_range_request, | |
response = zset_range_reply, | |
}), | |
zrevrange = command('ZREVRANGE', { | |
request = zset_range_request, | |
response = zset_range_reply, | |
}), | |
zrangebyscore = command('ZRANGEBYSCORE', { | |
request = zset_range_byscore_request, | |
response = zset_range_reply, | |
}), | |
zrevrangebyscore = command('ZREVRANGEBYSCORE', { -- >= 2.2 | |
request = zset_range_byscore_request, | |
response = zset_range_reply, | |
}), | |
zunionstore = command('ZUNIONSTORE', { -- >= 2.0 | |
request = zset_store_request | |
}), | |
zinterstore = command('ZINTERSTORE', { -- >= 2.0 | |
request = zset_store_request | |
}), | |
zcount = command('ZCOUNT'), | |
zcard = command('ZCARD'), | |
zscore = command('ZSCORE'), | |
zremrangebyscore = command('ZREMRANGEBYSCORE'), | |
zrank = command('ZRANK'), -- >= 2.0 | |
zrevrank = command('ZREVRANK'), -- >= 2.0 | |
zremrangebyrank = command('ZREMRANGEBYRANK'), -- >= 2.0 | |
zscan = command('ZSCAN', { -- >= 2.8 | |
request = scan_request, | |
response = zscan_response, | |
}), | |
-- commands operating on hashes | |
hset = command('HSET', { -- >= 2.0 | |
response = toboolean | |
}), | |
hsetnx = command('HSETNX', { -- >= 2.0 | |
response = toboolean | |
}), | |
hmset = command('HMSET', { -- >= 2.0 | |
request = hash_multi_request_builder(function(args, k, v) | |
table.insert(args, k) | |
table.insert(args, v) | |
end), | |
}), | |
hincrby = command('HINCRBY'), -- >= 2.0 | |
hincrbyfloat = command('HINCRBYFLOAT', {-- >= 2.6 | |
response = function(reply, command, ...) | |
return tonumber(reply) | |
end, | |
}), | |
hget = command('HGET'), -- >= 2.0 | |
hmget = command('HMGET', { -- >= 2.0 | |
request = hash_multi_request_builder(function(args, k, v) | |
table.insert(args, v) | |
end), | |
}), | |
hdel = command('HDEL'), -- >= 2.0 | |
hexists = command('HEXISTS', { -- >= 2.0 | |
response = toboolean | |
}), | |
hlen = command('HLEN'), -- >= 2.0 | |
hkeys = command('HKEYS'), -- >= 2.0 | |
hvals = command('HVALS'), -- >= 2.0 | |
hgetall = command('HGETALL', { -- >= 2.0 | |
response = function(reply, command, ...) | |
local new_reply = { } | |
for i = 1, #reply, 2 do new_reply[reply[i]] = reply[i + 1] end | |
return new_reply | |
end | |
}), | |
hscan = command('HSCAN', { -- >= 2.8 | |
request = scan_request, | |
response = hscan_response, | |
}), | |
-- connection related commands | |
ping = command('PING', { | |
response = function(response) return response == 'PONG' end | |
}), | |
echo = command('ECHO'), | |
auth = command('AUTH'), | |
select = command('SELECT'), | |
-- transactions | |
multi = command('MULTI'), -- >= 2.0 | |
exec = command('EXEC'), -- >= 2.0 | |
discard = command('DISCARD'), -- >= 2.0 | |
watch = command('WATCH'), -- >= 2.2 | |
unwatch = command('UNWATCH'), -- >= 2.2 | |
-- publish - subscribe | |
subscribe = command('SUBSCRIBE'), -- >= 2.0 | |
unsubscribe = command('UNSUBSCRIBE'), -- >= 2.0 | |
psubscribe = command('PSUBSCRIBE'), -- >= 2.0 | |
punsubscribe = command('PUNSUBSCRIBE'), -- >= 2.0 | |
publish = command('PUBLISH'), -- >= 2.0 | |
-- redis scripting | |
eval = command('EVAL'), -- >= 2.6 | |
evalsha = command('EVALSHA'), -- >= 2.6 | |
script = command('SCRIPT'), -- >= 2.6 | |
-- remote server control commands | |
bgrewriteaof = command('BGREWRITEAOF'), | |
config = command('CONFIG', { -- >= 2.0 | |
response = function(reply, command, ...) | |
if (type(reply) == 'table') then | |
local new_reply = { } | |
for i = 1, #reply, 2 do new_reply[reply[i]] = reply[i + 1] end | |
return new_reply | |
end | |
return reply | |
end | |
}), | |
client = command('CLIENT'), -- >= 2.4 | |
slaveof = command('SLAVEOF'), | |
save = command('SAVE'), | |
bgsave = command('BGSAVE'), | |
lastsave = command('LASTSAVE'), | |
flushdb = command('FLUSHDB'), | |
flushall = command('FLUSHALL'), | |
monitor = command('MONITOR'), | |
time = command('TIME'), -- >= 2.6 | |
slowlog = command('SLOWLOG', { -- >= 2.2.13 | |
response = function(reply, command, ...) | |
if (type(reply) == 'table') then | |
local structured = { } | |
for index, entry in ipairs(reply) do | |
structured[index] = { | |
id = tonumber(entry[1]), | |
timestamp = tonumber(entry[2]), | |
duration = tonumber(entry[3]), | |
command = entry[4], | |
} | |
end | |
return structured | |
end | |
return reply | |
end | |
}), | |
info = command('INFO', { | |
response = parse_info, | |
}), | |
} | |
-- ############################################################################ | |
return redis -- redis-lua module end ------------------------------------------- | |
end)() | |
local api = { | |
KEYS = { | |
type = "value", | |
description = "A table with the key names that were explicitly declared for the script to access.\n\nUse the `Command Line Parameters` to provide the script with parameters in `redis-cli --eval`-like format, i.e.: [key1 [key2 [...]]] , [arg1 [arg2 [...]]]" | |
}, | |
ARGV = { | |
type = "value", | |
description = "A table with the arguments that were passed to the script.\n\nUse the `Command Line Parameters` to provide the script with parameters in `redis-cli --eval`-like format, i.e.: [key1 [key2 [...]]] , [arg1 [arg2 [...]]]" | |
}, | |
redis = { | |
type = "lib", | |
description = "A library for inteacting with the Redis server in which the script is EVALed.", | |
childs = { | |
-- Values | |
REPL_ALL = { | |
type = "value", | |
description = "Target setting for calling redis.set_repl() that replicates effects both to AOF and slaves (the default).", | |
}, | |
REPL_AOF = { | |
type = "value", | |
description = "Target setting for calling redis.set_repl() that replicates effects only to AOF.", | |
}, | |
REPL_SLAVE = { | |
type = "value", | |
description = "Target setting for calling redis.set_repl() that replicates effects only to slaves.", | |
}, | |
REPL_NONE = { | |
type = "value", | |
description = "Target setting for calling redis.set_repl() that disables replication.", | |
}, | |
LOG_DEBUG = { | |
type = "value", | |
description = "DEBUG loglevel setting for calling redis.log().", | |
}, | |
LOG_VERBOSE = { | |
type = "value", | |
description = "VERBOSE loglevel setting for calling redis.log().", | |
}, | |
LOG_NOTICE = { | |
type = "value", | |
description = "NOTICE loglevel setting for calling redis.log().", | |
}, | |
LOG_WARNING = { | |
type = "value", | |
description = "WARNING loglevel setting for calling redis.log().", | |
}, | |
-- functions | |
error_reply = { | |
type = "function", | |
description = "Returns a Redis error reply.\nThis helper function returns a single field table with the err field set to the specified string.", | |
args = "(error: string)", | |
returns = "(table)", | |
}, | |
status_reply = { | |
type = "function", | |
description = "Returns a Redis status reply.\nThis helper function returns a single field table with the ok field set to the specified string.", | |
args = "(status: string)", | |
returns = "(table)", | |
}, | |
call = { | |
type = "function", | |
description = "Calls a Redis command, raising errors if any.\nIf the call to the command returns an error, the error will be raised and returned to the caller. To trap the error consider using pcall instead.\nThe arguments should be a well-formed Redis command. The return value depends on the command called and the dataset.", | |
args = "(command: string, [arg: string|number, ...])", | |
returns = "(number|string|table|boolean)", | |
}, | |
pcall = { | |
type = "function", | |
description = "Calls a Redis command, trapping errors if any.\nIf the call to the command returns an error, the error will be trapped and the function will return a table with the error. To raise the error to the caller consider using call instead.\nThe arguments should be a well-formed Redis command. The return value depends on the command called and the dataset.", | |
args = "(command: string, [arg: string|number, ...])", | |
returns = "(number|string|table|boolean)", | |
}, | |
replicate_commands = { | |
type = "function", | |
description = "Enables effects replication mode for the script.\nReturns true if successful.\n", | |
args = "()", | |
returns = "(boolean)", | |
}, | |
set_repl = { | |
type = "function", | |
description = "Sets the replication target when in effects replication mode.\n\nAccepts one of the following targets:\n* redis.REPL_ALL: Replicate to AOF and slaves (the default).\n* redis.REPL_AOF: Replicate only to AOF.\n* redis.REPL_SLAVE: Replicate only to slaves.\n* redis.REPL_NONE: Don't replicate at all.", | |
args = "(target: redis.REPL_(ALL|AOF|SLAVE|NONE))", -- TODO: replace with something else? | |
returns = "()", | |
}, | |
sha1hex = { | |
type = "function", | |
description = "Performs the SHA1 of the input string.", | |
args = "(input: string)", | |
returns = "(string)", | |
}, | |
breakpoint = { | |
type = "function", | |
description = "When in SCRIPT DEBUG mode, stops execution as if a breakpoint is encountered at the next line.", | |
args = "()", | |
returns = "()", | |
}, | |
debug = { | |
type = "function", | |
description = "When in SCRIPT DEBUG mode, emits a log message to the debug console.", | |
args = "(message: string)", | |
returns = "()", | |
}, | |
log = { | |
type = "function", | |
description = "Emits a log message to the Redis log file.\nAccepst one of the standard Redis loglevels and the message to emit.", | |
args = "(loglevel: redis.LOG_(DEBUG|VERBOSE|NOTICE|WARNING), message: string)", | |
returns = "()", | |
}, | |
}, | |
}, | |
struct = { | |
type = "lib", | |
description = "A library for packing/unpacking structures within Lua.", | |
childs = { | |
pack = { | |
type = "function", | |
description = "Returns an encoded representation of the arguments according to the specified format.\n\nValid formats:\n> - big endian\n< - little endian\n![num] - alignment\nx - padding\nb/B - signed/unsigned byte\nh/H - signed/unsigned short\nl/L - signed/unsigned long\nT - size_t\ni/In - signed/unsigned integer with size `n' (default is size of int)\ncn - sequence of `n' chars (from/to a string); when packing, n==0 means\n the whole string; when unpacking, n==0 means use the previous\n read number as the string length\ns - zero-terminated string\nf - float\nd - double\n' ' - ignored", | |
args = "(format: string, ...)", | |
returns = "(string)", | |
}, | |
unpack = { | |
type = "function", | |
description = "Returns the values from an encoded representation according to the specified format.\n\nValid formats:\n> - big endian\n< - little endian\n![num] - alignment\nx - padding\nb/B - signed/unsigned byte\nh/H - signed/unsigned short\nl/L - signed/unsigned long\nT - size_t\ni/In - signed/unsigned integer with size `n' (default is size of int)\ncn - sequence of `n' chars (from/to a string); when packing, n==0 means\n the whole string; when unpacking, n==0 means use the previous\n read number as the string length\ns - zero-terminated string\nf - float\nd - double\n' ' - ignored", | |
args = "(format: string, struct: string)", | |
returns = "(table)", -- TODO: the table also has an additional element??? | |
}, | |
size = { | |
type = "function", | |
description = "Returns the size of the encoded representation according to the specified format.\n\nValid formats:\n> - big endian\n< - little endian\n![num] - alignment\nx - padding\nb/B - signed/unsigned byte\nh/H - signed/unsigned short\nl/L - signed/unsigned long\nT - size_t\ni/In - signed/unsigned integer with size `n' (default is size of int)\ncn - sequence of `n' chars (from/to a string); when packing, n==0 means\n the whole string; when unpacking, n==0 means use the previous\n read number as the string length\ns - zero-terminated string\nf - float\nd - double\n' ' - ignored", | |
args = "(format: string)", | |
returns = "(number)" | |
}, | |
}, | |
}, | |
cjson = { | |
type = "lib", | |
description = "A library for encoding/decoding JSON.", | |
childs = { | |
encode = { | |
type = "function", | |
description = "Encodes a Lua table as a JSON document.", | |
args = "(input: table)", | |
returns = "(string)", | |
}, | |
decode = { | |
type = "function", | |
description = "Converts a JSON document to a Lua table.", | |
args = "(JSON: string)", | |
returns = "(table)", | |
}, | |
}, | |
}, | |
cmsgpack = { | |
type = "lib", | |
description = "A library for encoding/decoding MessagePack.", | |
childs = { | |
pack = { | |
type = "function", | |
description = "Encodes a Lua table as a MessagePack.", | |
args = "(input: table)", | |
returns = "(string)", | |
}, | |
unpack = { | |
type = "function", | |
description = "Converts a MessagePack to a Lua table.", | |
args = "(msgpack: string)", | |
returns = "(table)", | |
}, | |
}, | |
}, | |
bit = { | |
type = "lib", | |
description = "A library for bitwise operations on numbers.", | |
childs = { | |
tobit = { | |
type = "function", | |
description = "Normalizes a number to the numeric range for bit operations and returns it.", | |
args = "(x: number)", | |
returns = "(number)", | |
}, | |
tohex = { | |
type = "function", | |
description = "Converts its first argument to a hex string. The number of hex digits is given by the absolute value of the optional second argument. Positive numbers between 1 and 8 generate lowercase hex digits. Negative numbers generate uppercase hex digits. Only the least-significant 4*|n| bits are used. The default is to generate 8 lowercase hex digits.", | |
args = "(x: number [, n: number])", | |
returns = "(string)", | |
}, | |
bnot = { | |
type = "function", | |
description = "Returns the bitwise not of its argument.", | |
args = "(x: number)", | |
returns = "(number)", | |
}, | |
band = { | |
type = "function", | |
description = "Returns the bitwise and of all of its arguments.", | |
args = "(x1: number [, x2: number, ...])", | |
returns = "(number)", | |
}, | |
bor = { | |
type = "function", | |
description = "Returns the bitwise or of all of its arguments.", | |
args = "(x1: number [, x2: number, ...])", | |
returns = "(number)", | |
}, | |
bxor = { | |
type = "function", | |
description = "Returns the bitwise xor of all of its arguments.", | |
args = "(x1: number [, x2: number, ...])", | |
returns = "(number)", | |
}, | |
lshift = { | |
type = "function", | |
description = "Returns the bitwise logical left-shift of its first argument by the number of bits given by the second argument.\nTreats the first argument as an unsigned number and shifts in 0-bits.\nOnly the lower 5 bits of the shift count are used (reduces to the range [0..31]).", | |
args = "(x: number, n: number)", | |
returns = "(number)", | |
}, | |
rshift = { | |
type = "function", | |
description = "Returns the bitwise logical right-shift of its first argument by the number of bits given by the second argument.\nTreats the first argument as an unsigned number and shifts in 0-bits.\nOnly the lower 5 bits of the shift count are used (reduces to the range [0..31]).", | |
args = "(x: number, n: number)", | |
returns = "(number)", | |
}, | |
arshift = { | |
type = "function", | |
description = "Returns the bitwise arithmetic right-shift of its first argument by the number of bits given by the second argument.\nTreats the most-significant bit as a sign bit and replicates it.\nOnly the lower 5 bits of the shift count are used (reduces to the range [0..31]).", | |
args = "(x: number, n: number)", | |
returns = "(number)", | |
}, | |
rol = { | |
type = "function", | |
description = "Returns the bitwise left rotation of its first argument by the number of bits given by the second argument.\nBits shifted out on one side are shifted back in on the other side.\nOnly the lower 5 bits of the rotate count are used (reduces to the range [0..31]).", | |
args = "(x: number, n: number)", | |
returns = "(number)", | |
}, | |
ror = { | |
type = "function", | |
description = "Returns the bitwise right rotation of its first argument by the number of bits given by the second argument.\nBits shifted out on one side are shifted back in on the other side.\nOnly the lower 5 bits of the rotate count are used (reduces to the range [0..31]).", | |
args = "(x: number, n: number)", | |
returns = "(number)", | |
}, | |
bswap = { | |
type = "function", | |
description = "Swaps the bytes of its argument and returns it.", | |
args = "(x: number)", | |
returns = "(number)", | |
}, | |
}, | |
}, | |
} | |
local function isinstance(host, port, password) | |
local ok, res = pcall(redis.connect, {host = host, port = port, timeout = 0.5}) | |
if not ok then return nil, res:match("%[(.+)%]") end | |
-- check if the instance is password protected | |
local client = res | |
ok, res = pcall(client.ping, client) | |
if not ok and res:find("NOAUTH") | |
and (not password or not pcall(client.auth, client, password)) then | |
while true do | |
password = password or wx.wxGetPasswordFromUser("Enter Redis password", "Redis authentication") | |
if not password or password == "" then return end | |
if pcall(client.auth, client, password) then break end | |
password = nil | |
end | |
end | |
client:quit() | |
return true, password | |
end | |
local pkg | |
local address, password | |
local interpreter = { | |
name = "Redis", | |
description = "Redis interpreter", | |
api = {"baselib", "redis"}, | |
frun = function(self,wfilename,rundebug) | |
if not pkg then | |
ide:Print("Can't get package configuration.") | |
return | |
end | |
local filepath = wfilename:GetFullPath() | |
if ide.osname == 'Windows' then | |
-- if running on Windows and can't open the file, this may mean that | |
-- the file path includes unicode characters that need special handling | |
local fh = io.open(filepath, "r") | |
if fh then fh:close() end | |
if pcall(require, "winapi") and wfilename:FileExists() and not fh then | |
winapi.set_encoding(winapi.CP_UTF8) | |
filepath = winapi.short_path(filepath) | |
end | |
end | |
local defaddress = pkg:GetSettings().address or "redis://localhost:6379" | |
while true do | |
defaddress = address or wx.wxGetTextFromUser( | |
"Database URI ([redis://][username:password@]hostname:port[/db])", "Redis server", defaddress) | |
-- Redis currently ignores the username, should change with RCP1 | |
if not defaddress or defaddress == "" then return end | |
local uri = require('socket.url').parse(defaddress) | |
if uri.scheme ~= "redis" then return end -- need to think about support for sockets, future support for SSL/TLS | |
if not uri.host or not uri.port then | |
ide:Print(("Can't get host name or port number from URI '%s'."):format(defaddress)) | |
else | |
local ok, err = isinstance(uri.host, uri.port, (password or uri.password)) | |
if ok then | |
address, password = uri.scheme .. "://" .. uri.host .. ":" .. uri.port .. (uri.path or ""), err | |
pkg:SetSettings({address = address}) | |
break | |
elseif err then | |
ide:Print(("Can't connect to Redis instance '%s': %s."):format(defaddress, err)) | |
address = nil | |
else -- cancelled authentication | |
return | |
end | |
end | |
end | |
if rundebug then ide:GetDebugger():SetOptions() end | |
local pkgcfg = pkg:GetConfig() | |
local redis = " --instance "..address | |
local controller = " --controller "..ide:GetDebugger():GetHostName()..":"..ide:GetDebugger():GetPortNumber() | |
local rundebug = " --debug " .. (rundebug and (pkgcfg.debugmode or "yes") or "no") | |
local pswd = password and (" --password %q"):format(password) or "" | |
local verbose = pkgcfg.verbose and " --verbose" or "" | |
local maxlen = pkgcfg.maxlen and (" --maxlen %s"):format(pkgcfg.maxlen) or "" | |
local cfg = ide:GetConfig() | |
local params = cfg.arg.any or cfg.arg.redis | |
local exe = ide:GetInterpreters().luadeb:GetExePath("") | |
local cmd = ('"%s" "%s"%s "%s"%s'):format(exe, pkg:GetFilePath(), | |
table.concat({redis, controller, rundebug, pswd, maxlen, verbose}, ""), | |
filepath, params and " "..params or "") | |
-- CommandLineRun(cmd,wdir,tooutput,nohide,stringcallback,uid,endcallback) | |
return CommandLineRun(cmd,self:fworkdir(wfilename),true,false) | |
end, | |
hasdebugger = true, | |
fattachdebug = function(self) ide:GetDebugger():SetOptions() end, | |
unhideanywindow = false, | |
takeparameters = true, | |
} | |
local name = "redis" | |
local package = { | |
name = "Redis", | |
description = "Integrates with Redis.", | |
author = "Paul Kulchenko", | |
version = 0.37, | |
dependencies = "1.30", | |
onRegister = function(self) | |
pkg = self | |
ide:AddInterpreter(name, interpreter) | |
ide:AddAPI("lua", name, api) | |
end, | |
onUnRegister = function(self) | |
ide:RemoveInterpreter(name) | |
ide:RemoveAPI("lua", name) | |
end, | |
} | |
if pcall(debug.getlocal, 4, 1) then return package end | |
-- main redis script ----------------------------------------------------------- | |
io.stdout:setvbuf('no') | |
local unpack = unpack or table.unpack | |
local controller, instance = "localhost:8172", "redis://localhost:6379" | |
local verbose, maxlen, debugmode, password, params, file = false | |
while #arg > 0 do | |
local a = table.remove(arg, 1) | |
if a == "--instance" then | |
instance = table.remove(arg, 1) | |
elseif a == "--controller" then | |
controller = table.remove(arg, 1) | |
elseif a == "--debug" then | |
debugmode = table.remove(arg, 1) | |
elseif a == "--password" then | |
password = table.remove(arg, 1) | |
elseif a == "--maxlen" then | |
maxlen = table.remove(arg, 1) | |
elseif a == "--verbose" then | |
verbose = true | |
else | |
file, params, arg = a, arg, {} | |
end | |
end | |
local function check(cond, msg, ...) -- uses "file" name to inject into the error message | |
if cond or msg == nil then return cond, msg, ... end | |
print(type(msg) == 'string' and msg:gsub("(.+): @?user_script:%s*", "%1\n"..file..":") or msg) | |
os.exit(1) | |
end | |
check(file, "Required file name is not provided") | |
-- read the script | |
local fh, err = io.open(file, "rb") | |
check(fh, ("Can't open file '%s' for reading: %s"):format(file, err)) | |
local code = fh:read("*a") | |
fh:close() | |
local function getval(response, keyword, format, keep) | |
if type(response) ~= 'table' then return end | |
local res = {} | |
for _, v in ipairs(response) do | |
if type(v) == "string" and v:find("^"..keyword) then | |
table.insert(res, (format or "%q"):format(keep and v or v:gsub(keyword.." ",""))) | |
end | |
end | |
return res | |
end | |
local function getline(response) | |
local line = getval(response, "* Stopped at") | |
return line and line[1] and line[1]:match("(%d+)") | |
end | |
local function getretval(response) | |
return "return {"..table.concat(getval(response, "<retval>") or {}, ",").."}" | |
end | |
local function getreply(response) | |
local msg = table.concat(getval(response, "<reply>", "%s") or {}, ",") | |
-- add proper quoting to those messages that may be truncated because of `maxlen` limit | |
if msg:find('^"') and not msg:find('"$') then msg = msg..'"' end | |
return ("return {%s}"):format( | |
-- show returned NULL as `nil` | |
msg == "NULL" and "'nil'" or | |
-- show array (`[...]`) results from commands like `@hgetall something` as is | |
msg:find("^%[.+%]$") and ("%q"):format(msg) or | |
msg | |
) | |
end | |
local function geterror(response) | |
local err = getval(type(response) == 'table' and response or {response}, "<error>") | |
return err and #err > 0 and err[1] or nil | |
end | |
local function isdone(response) | |
local endsession = getval(response, "<endsession>") | |
return endsession and #endsession > 0 | |
end | |
local function isfilesame(fullname, fname, basedir) | |
local sep = "/" | |
fullname = fullname:gsub("[\\/]", sep) | |
basedir = basedir:gsub("[\\/]", sep):gsub(sep.."+$", "")..sep | |
return fullname == fname or fullname == basedir .. fname | |
end | |
local function removebasedir(fullname, basedir) | |
local sep = "/" | |
fullname = fullname:gsub("[\\/]", sep) | |
basedir = basedir:gsub("[\\/]", sep):gsub(sep.."+$", "")..sep | |
return (fullname:gsub(basedir, "")) | |
end | |
local function getvars(response) | |
if type(response) ~= 'table' then return end | |
-- convert reported values to local definitions; skip internal variables; | |
-- best effort handling of maxlen trimmed values; skip hints | |
-- <value> (for index) = 1 | |
-- <value> (for limit) = 20 | |
-- <value> i = 1 | |
-- <value> s = "foobar" | |
-- <value> t = {} | |
-- <value> trimmed_string = "abc ... | |
-- <value> trimmed_table = { "abc"; "ef ... | |
-- <hint> some text | |
local res = {} | |
for _, v in ipairs(response) do | |
if not v:find("<value> %(") and not v:find("<hint>") then | |
local val = v:gsub("<value> ", "") | |
if val:sub(-3) == "..." then -- trimmed string or table | |
local msg = "[[contents trimmed, consider using `maxlen 0`]]" | |
local var, valtype = val:match("^([%p%w]+)%s+=%s+(.)") | |
if valtype == "\"" then | |
val = val .. " " .. msg .. "\"" | |
elseif valtype == "{" then | |
val = var .. " = {\"" .. msg .. "\"}" | |
end | |
end | |
if loadstring("local "..val) then table.insert(res, val) end | |
end | |
end | |
return res | |
end | |
local function getvarsaslocals(response, max) | |
local vars = getvars(response) | |
if not vars or #vars == 0 then return "" end | |
local s = "" | |
for _, v in ipairs(vars) do | |
local news = s.."local "..v..";" | |
if max and #news > max then break end | |
s = news | |
end | |
return s | |
end | |
local function getvarsastable(response) | |
local vars = getvars(response) | |
for k, v in pairs(vars) do | |
local name, val = v:match("^([%p%w]+)%s+=%s+(.+)") | |
vars[k] = ("%s={%s,%s}"):format(name, val, val:find("^{") and "'table'" or "nil") | |
end | |
return "{"..table.concat(vars, "; ").."}" | |
end | |
local function reportdebug(response) | |
local deb = getval(response, "<debug>", "%s", true) | |
if deb and #deb > 0 then print(table.concat(deb, "\n")) end | |
local hint = getval(response, "<hint>", "%s", true) | |
if hint and #hint > 0 then print(table.concat(hint, "\n")) end | |
end | |
local function reportresult(client, msg) | |
local function report(msg) | |
if type(msg) ~= 'table' then return print(msg) end | |
for _, v in ipairs(msg) do report(v) end | |
end | |
report(msg or check(client:echo('nil'))) -- this will also report any errors | |
client:quit() | |
end | |
-- get redis instance host:port | |
local uri = require("socket.url").parse(instance) | |
local host, port, db = uri.host, uri.port, uri.path | |
check(host and port, ("Unknown Redis URI format '%s'; expected [redis://][username:password@]hostname:port[/db]"):format(instance)) | |
-- register Redis debugger commands | |
for key, command in pairs({continue = 'C', step = 'S', breakpoint = 'B', maxlen = 'M', | |
abort = 'A', print = 'P', eval = 'E', trace = 'T', redis = 'R'}) do | |
redis.commands['ldb'..key] = redis.command(command) | |
end | |
-- connect to redis instance | |
local ok, err = pcall(redis.connect, {host = host, port = port, timeout = nil}) | |
check(ok, ("Can't connect to Redis instance '%s': %s.") | |
:format(instance, type(err) == "string" and err:match("%[(.+)%]" or "Unknown error"))) | |
local client = err | |
client.error = function(error) return nil, (error:gsub(".*ERR ","")) end | |
if verbose then | |
local function formatmsg(msg) | |
return (("%q"):format(msg) | |
:gsub("\010","n"):gsub("\\0?13","\\r"):gsub("\026","\\026"):gsub('^"',""):gsub('"$',"")) | |
end | |
client.onread = function(self, line, err) print("<redis rcvd> "..(line and formatmsg(line) or "error: "..err)) end | |
client.onwrite = function(self, line, err) print("<redis sent> "..(line and formatmsg(line) or "error: "..err)) end | |
end | |
-- authenticate if password is provided | |
local msg, err = client:ping() | |
if not msg and err:find("NOAUTH") then | |
check(password, "Authentication is required, but no password is provided to authenticate.") | |
check(client:auth(password), "Can't autheticate with the provided password.") | |
end | |
-- select database, if any | |
if db then | |
msg, err = check(client:select(db:sub(2))) | |
end | |
local isdebugging = debugmode ~= "no" | |
msg, err = check(client:info("SERVER")) | |
local version = msg and tonumber(((msg.server or {}).redis_version or ""):match("%d+.%d+")) | |
local needversion = 3.2 | |
if isdebugging and version and version < needversion then | |
print(("Detected Redis server v%s, but need v%s+ for the debugging to work.") | |
:format(version, needversion)) | |
end | |
-- start debugging | |
if isdebugging then | |
msg, err = check(client:script("DEBUG", debugmode)) | |
end | |
-- split passed parameters into KEYS and ARGV (separated by ',') | |
local keys = #params | |
for n, v in ipairs(params) do | |
if v == ',' then | |
keys = n - 1 | |
table.remove(params, n) | |
break | |
end | |
end | |
-- if @file is requested, insert file contents | |
for n, v in ipairs(params) do | |
if string.sub(v,1,6) == "@file:" then | |
local f = assert(io.open(string.sub(v,7), "rb")) | |
params[n] = f:read("*all") | |
f:close() | |
end | |
end | |
-- load the script to debug; check for any reported errors | |
msg, err = check(client:eval(code, keys, unpack(params))) | |
-- if no debugging is requested, nothing else is needed to be done | |
if not isdebugging then | |
reportresult(client, msg) | |
os.exit(0) | |
end | |
-- set maxlen if provided | |
if maxlen then msg, err = check(client:ldbmaxlen(maxlen)) end | |
-- connect to the debugger | |
local server, err = socket.tcp() | |
check(server, ("Can't open socket: %s"):format(err)) | |
host, port = controller:match("^%s*(.+):(%d+)%s*$") | |
check(host and port, ("Unknown debugger address format '%s'; expected host:port."):format(controller)) | |
if server.settimeout then server:settimeout(2) end | |
local ok, err = server:connect(host, port) | |
if server.settimeout then server:settimeout() end | |
check(ok, ("Can't connect to the debugger at '%s': %s"):format(controller, err)) | |
-- main debugger loop | |
local basedir = "" | |
while true do | |
local line = check(server:receive()) | |
local command = string.sub(line, string.find(line, "^[A-Z]+")) | |
if command == "SETB" or command == "DELB" then | |
local _, _, _, lfile, line = string.find(line, "^([A-Z]+)%s+(.-)%s+(%d+)%s*$") | |
if lfile and line then | |
line = line == "0" and 0 or (command == "SETB" and tonumber(line) or -tonumber(line)) | |
-- deleting from file '*' deletes all breakpoints | |
if lfile == '*' or isfilesame(file, lfile, basedir) then client:ldbbreakpoint(line) end | |
server:send("200 OK\n") | |
else | |
server:send("400 Bad Request\n") | |
end | |
elseif command == "BASEDIR" then | |
local _, _, dir = string.find(line, "^[A-Z]+%s+(.+)%s*$") | |
if dir then | |
basedir = dir | |
server:send("200 OK\n") | |
else | |
server:send("400 Bad Request\n") | |
end | |
elseif command == "LOAD" then | |
local _, _, size = string.find(line, "^[A-Z]+%s+(%d+)%s+(%S.-)%s*$") | |
size = tonumber(size) | |
if size > 0 then server:receive(size) end | |
server:send("201 Started " .. file .. " " .. (getline(msg) or 1) .. "\n") | |
elseif command == "RUN" or command == "STEP" or command == "OVER" or command == "OUT" then | |
server:send("200 OK\n") | |
local msg, err | |
if command == "RUN" then | |
msg, err = check(client:ldbcontinue()) | |
else | |
msg, err = check(client:ldbstep()) | |
-- check if this is the last step stopped at "out of range" position; do one more step | |
local outofrange = msg and getval(msg, "->%s+%d+%s+<out of range") | |
if outofrange and #outofrange > 0 then | |
reportdebug(msg) | |
msg, err = check(client:ldbstep()) | |
end | |
end | |
reportdebug(msg) | |
if isdone(msg) then | |
reportresult(client) | |
break | |
end | |
server:send("202 Paused " .. file .. " " .. (getline(msg) or 0) .. "\n") | |
elseif command == "DONE" then | |
check(client:ldbbreakpoint(0)) -- remove all breakpoints | |
local msg = check(client:ldbcontinue()) -- continue with the script | |
reportdebug(msg) | |
if isdone(msg) then reportresult(client) end | |
break | |
elseif command == "EXEC" then | |
local _, _, chunk = string.find(line, "^[A-Z]+%s+(.+)$") | |
if chunk then | |
chunk = chunk:gsub("%-%-%s*%b{}","") -- remove eval options if present | |
local func, err = loadstring(chunk) | |
if not func then | |
local chunkr = "return "..chunk | |
func = loadstring(chunkr) | |
if func then chunk = chunkr end | |
end | |
if func then | |
-- evaluation is done in a different environment, so capture local variables | |
-- to use in the chunk evaluation to make their values available | |
local vars = getvarsaslocals(check(client:ldbprint()), 1024-#chunk) | |
local msg, err = client:ldbeval(vars..chunk) | |
-- if there is an error, try without preamble, as it has a better error message | |
if msg and geterror(msg) then msg, err = client:ldbeval(chunk) end | |
if msg and geterror(msg) then msg, err = nil, geterror(msg) end | |
if msg then | |
reportdebug(msg) | |
-- if the chunk starts from "return" or looks like an expression, then return the result | |
-- otherwise don't return any values to avoid showing `nil` after statements. | |
msg = (chunk:find("^return ") or loadstring("return "..chunk)) and getretval(msg) or "return {}" | |
server:send("200 OK " .. tostring(#msg) .. "\n") | |
server:send(msg) | |
else | |
server:send("401 Error in Expression " .. tostring(#err) .. "\n") | |
server:send(err) | |
end | |
elseif chunk:find("^[A-Z][A-Z]+%s") or chunk:find("^@") then | |
chunk = chunk:gsub("^@", "") | |
local cmd = {} | |
for v in chunk:gmatch("(%S+)") do table.insert(cmd, v) end | |
local msg, err = client:ldbredis(unpack(cmd)) | |
if msg and geterror(msg) then msg, err = nil, geterror(msg) end | |
if msg then | |
reportdebug(msg) | |
msg = getreply(msg) | |
server:send("200 OK " .. tostring(#msg) .. "\n") | |
server:send(msg) | |
else | |
server:send("401 Error in Expression " .. tostring(#err) .. "\n") | |
server:send(err) | |
end | |
else | |
server:send("401 Error in Expression " .. tostring(#err) .. "\n") | |
server:send(err) | |
end | |
else | |
server:send("400 Bad Request\n") | |
end | |
elseif command == "STACK" then | |
-- get stack information and repackage it in the expected format | |
local msg = check(client:ldbtrace()) | |
local fname = removebasedir(file, basedir) | |
local stack = {} | |
local vars = getvarsastable(check(client:ldbprint())) | |
while #msg > 0 do | |
local frame = table.remove(msg, 1) | |
local line = table.remove(msg, 1) | |
local top = frame:match("^In (.-):") | |
local funcname = frame:match("^In (.-):") or frame:match("^From (.-):") | |
if funcname == "top level" then | |
funcname = #msg > 0 and "anonymous function" or "main chunk" | |
end | |
local _, _, curline = line:find("(%d+)", 4) | |
table.insert(stack, ("{{%q, %q, -1, %d, 'main chunk', '', ''}, %s, {}}") | |
:format(funcname, fname, curline or 0, top and vars or "{}")) | |
end | |
server:send("200 OK " .. "return {"..table.concat(stack, ',').."}" .. "\n") | |
elseif command == "EXIT" then | |
client:ldbabort() | |
server:send("200 OK\n") | |
break | |
elseif command == "SETW" or command == "DELW" or command == "SUSPEND" or command == "OUTPUT" then | |
server:send("200 OK\n") | |
else | |
server:send("400 Bad Request\n") | |
end | |
end | |
--[[ configuration example: | |
redis = {debugmode = "sync"} -- set debug mode to "sync" (the default debug mode is "yes") | |
redis = {verbose = true} -- set verbose output to show all commands sent to or receivd from Redis | |
redis = {maxlen = 10000} -- set `maxlen` value during debugging | |
-- several settings can be combined: | |
redis = {maxlen = 10000, verbose = true} | |
--]] |