Editing Module:BucketGet
The edit can be undone.
Please check the comparison below to verify that this is what you want to do, and then publish the changes below to finish undoing the edit.
| Latest revision | Your text | ||
| Line 4: | Line 4: | ||
local getArgs = require('Module:Arguments').getArgs | local getArgs = require('Module:Arguments').getArgs | ||
local Bucket = mw.bucket | local Bucket = mw.bucket | ||
local TYPE_ORDER = { | local TYPE_ORDER = { ["nil"] = 1, number = 2, string = 3, boolean = 4, table = 5 } | ||
} | |||
local function compareValues(a, b) | local function compareValues(a, b) | ||
| Line 37: | Line 26: | ||
if a_type == 1 then | if a_type == 1 then | ||
return 0 | return 0 | ||
elseif a_type == 2 then | elseif a_type == 2 then | ||
if a < b then | if a < b then return -1 else return 1 end | ||
elseif a_type == 3 then | elseif a_type == 3 then | ||
if a < b then | if a < b then return -1 else return 1 end | ||
elseif a_type == 4 then | elseif a_type == 4 then | ||
if a == false and b == true then | if (a == false and b == true) then return -1 else return 1 end | ||
elseif a_type == 5 then | elseif a_type == 5 then | ||
local max_len = math.max(#a, #b) | local max_len = math.max(#a, #b) | ||
for i = 1, max_len do | for i = 1, max_len do | ||
local a_elem = a[i] | local a_elem = a[i] | ||
local b_elem = b[i] | local b_elem = b[i] | ||
local cmp = compareValues(a_elem, b_elem) | local cmp = compareValues(a_elem, b_elem) | ||
if cmp ~= 0 then | if cmp ~= 0 then | ||
return cmp | return cmp | ||
end | end | ||
end | end | ||
if #a < #b then | if #a < #b then | ||
return -1 | return -1 | ||
| Line 91: | Line 61: | ||
local cmp = compareValues(a_val, b_val) | local cmp = compareValues(a_val, b_val) | ||
if cmp ~= 0 then | if cmp ~= 0 then | ||
if order.direction == "ASC" then | if order.direction == "ASC" then | ||
| Line 100: | Line 69: | ||
end | end | ||
end | end | ||
return false | return false | ||
end) | end) | ||
end | end | ||
local function formatTable(frame, results, columns, args) | local function formatTable(frame, results, columns, args) | ||
local table_columns_str = args['table-columns'] | local table_columns_str = args['table-columns'] | ||
if table_columns_str then | if table_columns_str then | ||
local by_alias = {} | local by_alias = {} | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
by_alias[col.alias] = col | by_alias[col.alias] = col | ||
end | end | ||
local display_columns = {} | local display_columns = {} | ||
for part in table_columns_str:gmatch('[^,]+') do | for part in table_columns_str:gmatch('[^,]+') do | ||
local alias, new_label = | local alias, new_label = part:match('^%s*(.-)%s*=%s*(.-)%s*$') | ||
if not alias then | if not alias then | ||
alias = part:match('^%s*(.-)%s*$') | alias = part:match('^%s*(.-)%s*$') | ||
end | end | ||
local col = by_alias[alias] | local col = by_alias[alias] | ||
if col then | if col then | ||
if new_label then | if new_label then | ||
col.label = new_label | col.label = new_label | ||
end | end | ||
table.insert(display_columns, col) | table.insert(display_columns, col) | ||
end | end | ||
end | end | ||
columns = display_columns | columns = display_columns | ||
end | end | ||
local html = mw.html.create('table'):addClass('wikitable') | local html = mw.html.create('table'):addClass('wikitable') | ||
local header_row = html:tag('tr') | local header_row = html:tag('tr') | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
header_row:tag('th'):wikitext(col.label) | header_row:tag('th'):wikitext(col.label) | ||
| Line 153: | Line 105: | ||
for _, row in ipairs(results) do | for _, row in ipairs(results) do | ||
local tr = html:tag('tr') | local tr = html:tag('tr') | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
local data = row[col.alias] | local data = row[col.alias] | ||
if data == nil then | if data == nil then | ||
data = '<i>null</i>' | data = '<i>null</i>' | ||
| Line 162: | Line 112: | ||
data = tostring(data) | data = tostring(data) | ||
end | end | ||
tr:tag('td'):wikitext(data) | tr:tag('td'):wikitext(data) | ||
end | end | ||
end | end | ||
-- In case an earlier step introduced a template that needs to be preprocessed. | |||
if args['table-preprocess'] then | if args['table-preprocess'] then | ||
return frame:preprocess(tostring(html)) | return frame:preprocess(tostring(html)); | ||
end | end | ||
return tostring(html) | return tostring(html) | ||
end | end | ||
local function getDelimiter(args) | local function getDelimiter(args) | ||
| Line 182: | Line 128: | ||
return delimiter:gsub("\\n", "\n") | return delimiter:gsub("\\n", "\n") | ||
end | end | ||
local function formatTemplate(frame, results, columns, args) | local function formatTemplate(frame, results, columns, args) | ||
local template_name = args['template'] | local template_name = args['template'] | ||
if not template_name then | if not template_name then | ||
error("Template name must be specified for format=template") | error("Template name must be specified for format=template") | ||
end | end | ||
local delimiter = getDelimiter(args) | local delimiter = getDelimiter(args) | ||
local output = {} | local output = {} | ||
for _, row in ipairs(results) do | for _, row in ipairs(results) do | ||
local parts = {} | local parts = {} | ||
table.insert(parts, ("{{%s"):format(template_name)) | table.insert(parts, ("{{%s"):format(template_name)) | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
local value = row[col.alias] | local value = row[col.alias] | ||
if value == nil then | if value == nil then | ||
value = '' | value = '' | ||
elseif type(value) == "table" then | elseif type(value) == "table" then | ||
value = table.concat(value, '; ') | value = table.concat(value, '; ') | ||
else | else | ||
value = tostring(value) | value = tostring(value) | ||
end | end | ||
table.insert(parts, ("|%s=%s"):format(col.label, value)) | |||
table.insert( | |||
end | end | ||
| Line 225: | Line 156: | ||
end | end | ||
return frame:preprocess( | return frame:preprocess(table.concat(output, delimiter)) | ||
end | end | ||
local function parseColumns(columns_str) | local function parseColumns(columns_str) | ||
local columns = {} | local columns = {} | ||
for col_raw in columns_str:gmatch("[^,]+") do | for col_raw in columns_str:gmatch("[^,]+") do | ||
local column | local column, alias | ||
local col = col_raw:match("^%s*(.-)%s*$") | local col = col_raw:match("^%s*(.-)%s*$") | ||
local eq_pos = col:find("=") | local eq_pos = col:find("=") | ||
local as_pos, as_end = | local as_pos, as_end = col:lower():find("%sas%s") | ||
if eq_pos then | if eq_pos then | ||
column = | column = col:sub(1, eq_pos - 1):match("^%s*(.-)%s*$") | ||
alias = col:sub(eq_pos + 1):match("^%s*(.-)%s*$") | |||
alias = | |||
elseif as_pos then | elseif as_pos then | ||
column = | column = col:sub(1, as_pos - 1):match("^%s*(.-)%s*$") | ||
alias = col:sub(as_end + 1):match("^%s*(.-)%s*$") | |||
alias = | |||
else | else | ||
column = col | column = col | ||
| Line 266: | Line 179: | ||
end | end | ||
table.insert(columns, { | table.insert(columns, { column = column, alias = alias }) | ||
end | end | ||
return columns | return columns | ||
end | end | ||
-- | -- Convert column names to their alias | ||
local function renameColumns(results, columns) | local function renameColumns(results, columns) | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
| Line 289: | Line 195: | ||
end | end | ||
end | end | ||
local function applySplitColumns(results, columns, args) | local function applySplitColumns(results, columns, args) | ||
local split_specs = {} | local split_specs = {} | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
local val = args[col.alias .. '-split'] | local val = args[col.alias .. '-split'] | ||
if val ~= nil then | if val ~= nil then | ||
local count | local count | ||
if val == 'auto' then | if val == 'auto' then | ||
count = 'auto' | count = 'auto' | ||
elseif tonumber(val) and tonumber(val) >= 1 then | elseif tonumber(val) and tonumber(val) >= 1 then | ||
count = tonumber(val) | count = tonumber(val) | ||
else | else | ||
error(string.format( | error(string.format("Invalid %s-split value '%s': must be 'auto' or a positive integer", col.alias, val)) | ||
end | end | ||
table.insert(split_specs, {alias=col.alias, count=count}) | |||
table.insert(split_specs, { | |||
end | end | ||
end | end | ||
| Line 329: | Line 218: | ||
for _, spec in ipairs(split_specs) do | for _, spec in ipairs(split_specs) do | ||
-- resolve 'auto' counts: max table length across all rows | |||
-- | |||
if spec.count == 'auto' then | if spec.count == 'auto' then | ||
local max_n = 0 | local max_n = 0 | ||
for _, row in ipairs(results) do | for _, row in ipairs(results) do | ||
local v = row[spec.alias] | local v = row[spec.alias] | ||
if type(v) == 'table' and #v > max_n then | if type(v) == 'table' and #v > max_n then | ||
max_n = #v | max_n = #v | ||
end | end | ||
end | end | ||
spec.count = max_n | spec.count = max_n | ||
end | end | ||
-- create new columns based on count | |||
-- | |||
for i = 1, spec.count do | for i = 1, spec.count do | ||
local sub_alias = spec.alias .. i | local sub_alias = spec.alias .. i | ||
table.insert(columns, { | table.insert(columns, { | ||
alias = sub_alias, | alias=sub_alias, | ||
label = args[sub_alias .. '-label'] or sub_alias | label=args[sub_alias .. '-label'] or sub_alias | ||
}) | }) | ||
end | end | ||
| Line 359: | Line 242: | ||
for _, spec in ipairs(split_specs) do | for _, spec in ipairs(split_specs) do | ||
local v = row[spec.alias] | local v = row[spec.alias] | ||
for i = 1, spec.count do | for i = 1, spec.count do | ||
local val | local val | ||
if type(v) == 'table' then | if type(v) == 'table' then | ||
val = v[i] | val = v[i] | ||
| Line 368: | Line 249: | ||
val = (i == 1) and v | val = (i == 1) and v | ||
end | end | ||
row[spec.alias .. i] = val or "" | |||
row[spec.alias .. i] = val | |||
end | end | ||
end | end | ||
end | end | ||
end | end | ||
local function translateAliases(node, alias_to_column) | local function translateAliases(node, alias_to_column) | ||
| Line 390: | Line 259: | ||
translateAliases(node.left, alias_to_column) | translateAliases(node.left, alias_to_column) | ||
translateAliases(node.right, alias_to_column) | translateAliases(node.right, alias_to_column) | ||
elseif node.type == 'not' then | elseif node.type == 'not' then | ||
translateAliases(node.operand, alias_to_column) | translateAliases(node.operand, alias_to_column) | ||
elseif node.type == 'comparison' or node.type == 'filter_op' then | |||
elseif node.type == 'comparison' | |||
local original = alias_to_column[node.field] | local original = alias_to_column[node.field] | ||
if original then | if original then | ||
node.field = original | node.field = original | ||
| Line 404: | Line 268: | ||
end | end | ||
end | end | ||
local function parseOrderBy(order_by_str) | local function parseOrderBy(order_by_str) | ||
local order_by = {} | local order_by = {} | ||
for col_raw in order_by_str:gmatch("[^,]+") do | for col_raw in order_by_str:gmatch("[^,]+") do | ||
local col = mw.text.trim(col_raw) | local col = mw.text.trim(col_raw) | ||
local column, direction | |||
local column | |||
local lower_col = col:lower() | local lower_col = col:lower() | ||
local space_pos = lower_col:find("%s+") | local space_pos = lower_col:find("%s+") | ||
if space_pos then | if space_pos then | ||
column = | column = mw.text.trim(col:sub(1, space_pos - 1)) | ||
local dir = mw.text.trim(col:sub(space_pos + 1)):lower() | |||
local dir = | |||
if dir == "desc" then | if dir == "desc" then | ||
direction = "DESC" | direction = "DESC" | ||
elseif dir == "asc" then | elseif dir == "asc" then | ||
direction = "ASC" | direction = "ASC" | ||
else | else | ||
error( | error(("Sort direction must be either ASC or DESC. %s provided."):format(dir)) | ||
end | end | ||
else | else | ||
column = col | column = col | ||
| Line 450: | Line 292: | ||
end | end | ||
table.insert(order_by, { | table.insert(order_by, { column = column, direction = direction }) | ||
end | end | ||
return order_by | return order_by | ||
end | end | ||
local function unescape(text) | local function unescape(text) | ||
| Line 467: | Line 301: | ||
return text | return text | ||
end | end | ||
local unescaped = mw.text.unstripNoWiki(text) | local unescaped = mw.text.unstripNoWiki(text) | ||
local text, count = string.gsub(unescaped, "<", "<") | |||
local text, count = | text, count = string.gsub(text, ">", ">") | ||
text, count = string.gsub(text, "<//nowiki>", "</nowiki>") | |||
text, count = | |||
text, count = | |||
return text | return text | ||
end | end | ||
local function formatUserformat(frame, results, columns, args) | local function formatUserformat(frame, results, columns, args) | ||
local formatter = args['userformat'] | local formatter = args['userformat'] | ||
if not formatter then | if not formatter then | ||
error("format=userformat expects an accompanying formatter") | error("format=userformat expects an accompanying formatter") | ||
end | end | ||
formatter = unescape(formatter) | formatter = unescape(formatter) | ||
local delimiter = getDelimiter(args) | |||
local output = {} | local output = {} | ||
for _, row in ipairs(results) do | for _, row in ipairs(results) do | ||
local formatted = formatter | local formatted = formatter | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
local value = | local value = row[col.alias] | ||
if value == nil then | |||
value = '' | |||
) | elseif type(value) == "table" then | ||
value = table.concat(value, '; ') | |||
local placeholder = | else | ||
value = tostring(value) | |||
end | |||
formatted = | local placeholder = "%%" .. col.label .. "%%" | ||
formatted = formatted:gsub(placeholder, function() return value end) | |||
end | end | ||
table.insert(output, formatted) | table.insert(output, formatted) | ||
end | end | ||
return frame:preprocess( | return frame:preprocess(table.concat(output, delimiter)) | ||
end | end | ||
local function parseGroupBy(group_by_str) | local function parseGroupBy(group_by_str) | ||
local group_by = {} | local group_by = {} | ||
for col_raw in group_by_str:gmatch("[^,]+") do | for col_raw in group_by_str:gmatch("[^,]+") do | ||
local col = mw.text.trim(col_raw) | local col = mw.text.trim(col_raw) | ||
table.insert(group_by, col) | table.insert(group_by, col) | ||
end | end | ||
return group_by | return group_by | ||
end | end | ||
local function parseAggregateArg(aggregate_str, columns) | local function parseAggregateArg(aggregate_str, columns) | ||
local agg_specs = {} | local agg_specs = {} | ||
if not aggregate_str or aggregate_str:match('^%s*$') then | |||
if not aggregate_str | |||
return agg_specs | return agg_specs | ||
end | end | ||
for spec_str in aggregate_str:gmatch('[^,]+') do | for spec_str in aggregate_str:gmatch('[^,]+') do | ||
spec_str = | spec_str = spec_str:match('^%s*(.-)%s*$') | ||
-- Check for AS alias | |||
local output_col, main_part | |||
local as_pos, as_end = spec_str:lower():find('%s+as%s+') | |||
if as_pos then | if as_pos then | ||
main_part = | main_part = spec_str:sub(1, as_pos - 1):match('^%s*(.-)%s*$') | ||
output_col = spec_str:sub(as_end + 1):match('^%s*(.-)%s*$') | |||
output_col = | |||
else | else | ||
main_part = spec_str | main_part = spec_str | ||
| Line 635: | Line 366: | ||
end | end | ||
local func_name, args_str = | -- Check for FUNC(args) pattern | ||
local func_name, args_str = main_part:match('^(%w+)%s*%((.*)%)%s*$') | |||
if func_name then | if func_name then | ||
func_name = func_name:upper() | func_name = func_name:upper() | ||
local args = {} | local args = {} | ||
for arg in args_str:gmatch('%S+') do | for arg in args_str:gmatch('%S+') do | ||
table.insert(args, arg) | table.insert(args, arg) | ||
| Line 654: | Line 380: | ||
local is_new_col = true | local is_new_col = true | ||
for _, col_info in ipairs(columns) do | for _, col_info in ipairs(columns) do | ||
if col_info.alias == output_col then | if col_info.alias == output_col then | ||
| Line 668: | Line 393: | ||
is_new_col = is_new_col | is_new_col = is_new_col | ||
}) | }) | ||
else | else | ||
-- Bare column name → TABLE | -- Bare column name → TABLE | ||
local col_name = | local col_name = main_part:match('^%s*(.-)%s*$') | ||
if col_name ~= '' then | if col_name ~= '' then | ||
if not output_col then | if not output_col then | ||
output_col = col_name | output_col = col_name | ||
end | end | ||
local is_new_col = true | local is_new_col = true | ||
for _, col_info in ipairs(columns) do | for _, col_info in ipairs(columns) do | ||
if col_info.alias == output_col then | if col_info.alias == output_col then | ||
| Line 688: | Line 407: | ||
end | end | ||
end | end | ||
table.insert(agg_specs, { | table.insert(agg_specs, { | ||
func = 'TABLE', | func = 'TABLE', | ||
| Line 698: | Line 416: | ||
end | end | ||
end | end | ||
return agg_specs | return agg_specs | ||
end | end | ||
local function aggCount(rows, column) | local function aggCount(rows, column) | ||
| Line 712: | Line 425: | ||
local function aggTable(rows, column) | local function aggTable(rows, column) | ||
local result = {} | local result = {} | ||
for _, row in ipairs(rows) do | for _, row in ipairs(rows) do | ||
local value = row[column] | local value = row[column] | ||
if value ~= nil then | if value ~= nil then | ||
if type(value) == "table" then | if type(value) == "table" then | ||
| Line 726: | Line 437: | ||
end | end | ||
end | end | ||
return result | return result | ||
end | end | ||
| Line 732: | Line 442: | ||
local function aggMin(rows, column) | local function aggMin(rows, column) | ||
local min_val = nil | local min_val = nil | ||
for _, row in ipairs(rows) do | for _, row in ipairs(rows) do | ||
local v = tonumber(row[column]) or row[column] | local v = tonumber(row[column]) or row[column] | ||
if min_val == nil or v < min_val then min_val = v end | |||
if min_val == nil or v < min_val then | |||
end | end | ||
return min_val | return min_val | ||
end | end | ||
| Line 746: | Line 451: | ||
local function aggMax(rows, column) | local function aggMax(rows, column) | ||
local max_val = nil | local max_val = nil | ||
for _, row in ipairs(rows) do | for _, row in ipairs(rows) do | ||
local v = tonumber(row[column]) or row[column] | local v = tonumber(row[column]) or row[column] | ||
if max_val == nil or v > max_val then max_val = v end | |||
if max_val == nil or v > max_val then | |||
end | end | ||
return max_val | return max_val | ||
end | end | ||
| Line 761: | Line 461: | ||
return rows[1][column] | return rows[1][column] | ||
end | end | ||
local function applyValueFormats(results, columns, args, frame) | local function applyValueFormats(results, columns, args, frame) | ||
local formats_to_apply = {} | local formats_to_apply = {} | ||
local link_columns = { | local link_columns = { page_name = true, page_name_sub = true } | ||
local table_name = args[1] | local table_name = args[1] | ||
local data = | local data = mw.loadJsonData("Bucket:" .. table_name) | ||
for column, info in pairs(data) do | for column, info in pairs(data) do | ||
if info['type'] == 'PAGE' then | if info['type'] == 'PAGE' then | ||
| Line 785: | Line 476: | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
local format_key = | local format_key = col.alias .. "-format" | ||
if args[format_key] or type(results[1][col.alias]) == "table" then | |||
local formatter = args[format_key] or (link_columns[col.column] and "[[%s]]" or "%s") | |||
if args[format_key] | local delimiter = args[col.alias .. "-delimiter"] or "; " | ||
delimiter = delimiter:gsub("\\n", "\n") | |||
local formatter = | |||
local delimiter = | |||
delimiter = | |||
table.insert(formats_to_apply, { | table.insert(formats_to_apply, { | ||
| Line 821: | Line 497: | ||
for _, fmt_info in ipairs(formats_to_apply) do | for _, fmt_info in ipairs(formats_to_apply) do | ||
local value = row[fmt_info.column] | local value = row[fmt_info.column] | ||
if value == nil then | |||
value = '' | |||
if value == nil | |||
elseif type(value) == "table" then | elseif type(value) == "table" then | ||
local formatted_values = {} | local formatted_values = {} | ||
for _, v in ipairs(value) do | for _, v in ipairs(value) do | ||
local formatted = fmt_info.format:gsub("%%s", function() return v or '' end) | |||
table.insert(formatted_values, formatted) | |||
end | end | ||
row[fmt_info.column] = table.concat(formatted_values, fmt_info.delimiter) | |||
row[fmt_info.column] = | |||
else | else | ||
local formatted = | local formatted = fmt_info.format:gsub("%%s", function() return value end) | ||
row[fmt_info.column] = formatted | row[fmt_info.column] = formatted | ||
end | end | ||
| Line 898: | Line 515: | ||
return results | return results | ||
end | end | ||
local function buildGroups(results, group_by_cols) | local function buildGroups(results, group_by_cols) | ||
local groups = {} | local groups = {} | ||
local group_order = {} | local group_order = {} | ||
for _, row in ipairs(results) do | for _, row in ipairs(results) do | ||
local key_parts = {} | local key_parts = {} | ||
for _, col in ipairs(group_by_cols) do | for _, col in ipairs(group_by_cols) do | ||
local value = row[col] | local value = row[col] | ||
if type(value) == 'table' then | if type(value) == 'table' then | ||
table.insert( | table.insert(key_parts, table.concat(value, '\0')) | ||
elseif value == nil then | elseif value == nil then | ||
table.insert(key_parts, '\0') | table.insert(key_parts, '\0') | ||
else | else | ||
table.insert( | table.insert(key_parts, tostring(value)) | ||
end | end | ||
end | end | ||
local key = table.concat(key_parts, '\1') | |||
local key = | |||
if not groups[key] then | if not groups[key] then | ||
groups[key] = {} | groups[key] = {} | ||
table.insert(group_order, key) | table.insert(group_order, key) | ||
end | end | ||
table.insert(groups[key], row) | table.insert(groups[key], row) | ||
end | end | ||
return groups, group_order | return groups, group_order | ||
end | end | ||
local function applyAggregation(group_rows, agg_specs, new_row) | local function applyAggregation(group_rows, agg_specs, new_row) | ||
local format_specs = {} | local format_specs = {} | ||
for _, spec in ipairs(agg_specs) do | for _, spec in ipairs(agg_specs) do | ||
if spec.func == 'FORMAT' then | if spec.func == 'FORMAT' then | ||
table.insert(format_specs, spec) | table.insert(format_specs, spec) | ||
elseif spec.func == 'COUNT' then | elseif spec.func == 'COUNT' then | ||
new_row[spec.output_col] = | new_row[spec.output_col] = aggCount(group_rows, spec.args[1]) | ||
elseif spec.func == 'TABLE' then | elseif spec.func == 'TABLE' then | ||
new_row[spec.output_col] = | new_row[spec.output_col] = aggTable(group_rows, spec.args[1]) | ||
elseif spec.func == 'MIN' then | elseif spec.func == 'MIN' then | ||
new_row[spec.output_col] = | new_row[spec.output_col] = aggMin(group_rows, spec.args[1]) | ||
elseif spec.func == 'MAX' then | elseif spec.func == 'MAX' then | ||
new_row[spec.output_col] = | new_row[spec.output_col] = aggMax(group_rows, spec.args[1]) | ||
elseif spec.func == 'ANY' then | elseif spec.func == 'ANY' then | ||
new_row[spec.output_col] = | new_row[spec.output_col] = aggAny(group_rows, spec.args[1]) | ||
end | end | ||
end | end | ||
return format_specs | return format_specs | ||
end | end | ||
| Line 998: | Line 563: | ||
local function applyFormatSpec(new_row, spec, fmt_template) | local function applyFormatSpec(new_row, spec, fmt_template) | ||
local array_len = nil | local array_len = nil | ||
for _, arg_col in ipairs(spec.args) do | for _, arg_col in ipairs(spec.args) do | ||
if type(new_row[arg_col]) == 'table' then | if type(new_row[arg_col]) == 'table' then | ||
| Line 1,008: | Line 572: | ||
if array_len then | if array_len then | ||
local results = {} | local results = {} | ||
for i = 1, array_len do | for i = 1, array_len do | ||
local result_str = fmt_template | local result_str = fmt_template | ||
for _, arg_col in ipairs(spec.args) do | for _, arg_col in ipairs(spec.args) do | ||
local val = new_row[arg_col] | local val = new_row[arg_col] | ||
local sv | local sv | ||
if type(val) == 'table' then | if type(val) == 'table' then | ||
sv = | sv = tostring(val[i] ~= nil and val[i] or '') | ||
elseif val == nil then | elseif val == nil then | ||
sv = '' | sv = '' | ||
else | else | ||
sv = tostring(val) | sv = tostring(val) | ||
end | end | ||
result_str = result_str:gsub('%%' .. arg_col .. '%%', function() return sv end) | |||
result_str = | |||
end | end | ||
table.insert(results, result_str) | table.insert(results, result_str) | ||
end | end | ||
new_row[spec.output_col] = results | new_row[spec.output_col] = results | ||
else | else | ||
local result_str = fmt_template | local result_str = fmt_template | ||
for _, arg_col in ipairs(spec.args) do | for _, arg_col in ipairs(spec.args) do | ||
local val = new_row[arg_col] | local val = new_row[arg_col] | ||
result_str = result_str:gsub('%%' .. arg_col .. '%%', | |||
result_str = | function() return val == nil and '' or tostring(val) end) | ||
end | end | ||
new_row[spec.output_col] = result_str | new_row[spec.output_col] = result_str | ||
end | end | ||
end | end | ||
local function groupResults( | local function groupResults(results, group_by_cols, agg_specs, columns, group_sort, args) | ||
) | |||
if #group_by_cols == 0 then | if #group_by_cols == 0 then | ||
return results | return results | ||
| Line 1,079: | Line 606: | ||
local group_by_set = {} | local group_by_set = {} | ||
for _, col in ipairs(group_by_cols) do | for _, col in ipairs(group_by_cols) do | ||
group_by_set[col] = true | group_by_set[col] = true | ||
| Line 1,085: | Line 611: | ||
local covered = {} | local covered = {} | ||
for _, spec in ipairs(agg_specs) do | for _, spec in ipairs(agg_specs) do | ||
covered[spec.output_col] = true | covered[spec.output_col] = true | ||
end | end | ||
local groups, group_order = | local groups, group_order = buildGroups(results, group_by_cols) | ||
local grouped_results = {} | local grouped_results = {} | ||
for _, key in ipairs(group_order) do | for _, key in ipairs(group_order) do | ||
local group_rows = groups[key] | local group_rows = groups[key] | ||
if group_sort then | if group_sort then | ||
sortResults( | sortResults(group_rows, group_sort) | ||
end | end | ||
local new_row = {} | local new_row = {} | ||
for _, col in ipairs(group_by_cols) do | for _, col in ipairs(group_by_cols) do | ||
new_row[col] = | new_row[col] = group_rows[1][col] | ||
end | end | ||
local format_specs = | local format_specs = applyAggregation(group_rows, agg_specs, new_row) | ||
for _, col_info in ipairs(columns) do | for _, col_info in ipairs(columns) do | ||
local col_name = col_info.alias | local col_name = col_info.alias | ||
if not group_by_set[col_name] and not covered[col_name] then | |||
if not group_by_set[col_name] | new_row[col_name] = aggAny(group_rows, col_name) | ||
new_row[col_name] = | |||
end | end | ||
end | end | ||
for _, spec in ipairs(format_specs) do | for _, spec in ipairs(format_specs) do | ||
local fmt_template = | local fmt_template = args[spec.output_col .. '-agg'] | ||
if fmt_template then | if fmt_template then | ||
applyFormatSpec( | applyFormatSpec(new_row, spec, unescape(fmt_template)) | ||
end | end | ||
end | end | ||
table.insert( | table.insert(grouped_results, new_row) | ||
end | end | ||
return grouped_results | return grouped_results | ||
end | end | ||
local T = { PARENTHESIS_L = 1, PARENTHESIS_R = 2, AND = 3, OR = 4, NOT = 5, COMP = 7, STR = 8, NUM = 9, BOOL = 10, ID = 11, EOF = 12 } | |||
local T = { | |||
} | |||
local KW = { | local KW = { | ||
['AND'] = T.AND, | ['AND'] = T.AND, | ||
| Line 1,182: | Line 656: | ||
['NOT'] = T.NOT, | ['NOT'] = T.NOT, | ||
['true'] = T.BOOL, | ['true'] = T.BOOL, | ||
['false'] = T.BOOL | ['false'] = T.BOOL | ||
} | } | ||
local FILTER_OPS = { CONTAINS = true, ICONTAINS = true, MATCH = true } | |||
local FILTER_OPS = { | |||
} | |||
local function parseJoins(join_str) | local function parseJoins(join_str) | ||
local joins = {} | local joins = {} | ||
for join_part in join_str:gmatch("[^,]+") do | for join_part in join_str:gmatch("[^,]+") do | ||
local join = {} | local join = {} | ||
local parts = {} | local parts = {} | ||
for part in join_part:gmatch("[^=]+") do | for part in join_part:gmatch("[^=]+") do | ||
table.insert( | table.insert(parts, mw.text.trim(part)) | ||
end | end | ||
if #parts == 2 then | if #parts == 2 then | ||
join.left = parts[1] | join.left = parts[1] | ||
join.right = parts[2] | join.right = parts[2] | ||
table.insert(joins, join) | |||
table.insert( | |||
end | end | ||
end | end | ||
return joins | return joins | ||
end | end | ||
local function tokenize(s) | local function tokenize(s) | ||
local t, i = {}, 1 | local t, i = {}, 1 | ||
while i <= #s do | while i <= #s do | ||
local c = s:sub(i, i) | local c = s:sub(i, i) | ||
if c:match('%s') then | if c:match('%s') then | ||
i = i + 1 | i = i + 1 | ||
elseif c == '(' then | elseif c == '(' then | ||
table.insert( | table.insert(t, { type = T.PARENTHESIS_L }); i = i + 1 | ||
elseif c == ')' then | elseif c == ')' then | ||
table.insert( | table.insert(t, { type = T.PARENTHESIS_R }); i = i + 1 | ||
elseif c:match('%d') or c == '.' then | elseif c:match('%d') or c == '.' then | ||
local n = | local n = s:match('^%-?%d*%.?%d+', i) | ||
table.insert(t, { type = T.NUM, val = tonumber(n) }) | |||
table.insert( | |||
i = i + #n | i = i + #n | ||
elseif c:match("[%w_]") then | elseif c:match("[%w_]") then | ||
local w = | local w = s:match('^[%w_.]+', i) | ||
table.insert(t, { type = KW[w:upper()] or KW[w] or T.ID, val = w }) | |||
table.insert( | |||
i = i + #w | i = i + #w | ||
elseif c:match('["\']') then | elseif c:match('["\']') then | ||
local q, str = c, '' | local q, str = c, '' | ||
for j = i + 1, #s do | for j = i + 1, #s do | ||
local ch = s:sub(j, j) | local ch = s:sub(j, j) | ||
if ch == q then | if ch == q then | ||
table.insert( | table.insert(t, { type = T.STR, val = str }); i = j + 1; break | ||
end | end | ||
str = str .. ch | str = str .. ch | ||
end | end | ||
elseif c:match('%d') or c == '.' then | |||
local n = s:match('^%-?%d*%.?%d+', i) | |||
table.insert(t, { type = T.NUM, val = tonumber(n) }) | |||
i = i + #n | |||
elseif c:match('[=<>!]') then | elseif c:match('[=<>!]') then | ||
local op = | local op = s:match('[=<>!]+', i) | ||
if not op then | if not op then | ||
error( | error("Invalid operator at position " .. i) | ||
end | end | ||
table.insert(t, { type = T.COMP, val = op }) | |||
table.insert( | |||
i = i + #op | i = i + #op | ||
else | else | ||
error( | error("Unexpected character '" .. c .. "' at position " .. i) | ||
end | end | ||
end | end | ||
return t | return t | ||
end | end | ||
-- | -- Break out of the dependency loop | ||
local parsePrimary | local parsePrimary | ||
local function parseNotExpr(toks, pos) | local function parseNotExpr(toks, pos) | ||
if toks[pos] | if toks[pos] and toks[pos].type == T.NOT then | ||
local node, new_pos = parseNotExpr(toks, pos + 1) | |||
return { type = 'not', operand = node }, new_pos | |||
local node, new_pos = | |||
return { | |||
else | else | ||
return parsePrimary( | return parsePrimary(toks, pos) | ||
end | end | ||
end | end | ||
local function parseAndExpr(toks, pos) | local function parseAndExpr(toks, pos) | ||
local left, new_pos = | local left, new_pos = parseNotExpr(toks, pos) | ||
while new_pos <= #toks and toks[new_pos].type == T.AND do | |||
local right, next_pos = parseNotExpr(toks, new_pos + 1) | |||
left = { type = 'and', left = left, right = right } | |||
while new_pos <= #toks | |||
local right, next_pos = | |||
left = { | |||
new_pos = next_pos | new_pos = next_pos | ||
end | end | ||
return left, new_pos | return left, new_pos | ||
end | end | ||
local function parseExpression(toks, pos) | local function parseExpression(toks, pos) | ||
local left, new_pos = | local left, new_pos = parseAndExpr(toks, pos) | ||
while new_pos <= #toks and toks[new_pos].type == T.OR do | |||
local right, next_pos = parseAndExpr(toks, new_pos + 1) | |||
left = { type = 'or', left = left, right = right } | |||
while new_pos <= #toks | |||
local right, next_pos = | |||
left = { | |||
new_pos = next_pos | new_pos = next_pos | ||
end | end | ||
return left, new_pos | return left, new_pos | ||
end | end | ||
| Line 1,436: | Line 756: | ||
parsePrimary = function(toks, pos) | parsePrimary = function(toks, pos) | ||
if toks[pos].type == T.PARENTHESIS_L then | if toks[pos].type == T.PARENTHESIS_L then | ||
local node, new_pos = | local node, new_pos = parseExpression(toks, pos + 1) | ||
if toks[new_pos].type ~= T.PARENTHESIS_R then | |||
error("Expected ')' at position " .. new_pos) | |||
if toks[new_pos].type | |||
error( | |||
end | end | ||
return node, new_pos + 1 | return node, new_pos + 1 | ||
elseif toks[pos].type == T.ID then | elseif toks[pos].type == T.ID then | ||
local op_tok = toks[pos + 1] | local op_tok = toks[pos + 1] | ||
if op_tok and op_tok.type == T.ID and FILTER_OPS[op_tok.val:upper()] then | |||
if op_tok | |||
local v = toks[pos + 2] | local v = toks[pos + 2] | ||
if not v or (v.type ~= T.STR and v.type ~= T.NUM and v.type ~= T.BOOL and v.type ~= T.ID) then | |||
if not v | error("Expected value after filter operator at position " .. (pos + 2)) | ||
error( | |||
end | end | ||
local value = (v.type == T.NUM) and v.val or tostring(v.val) | |||
local value = | return { type = 'filter_op', field = toks[pos].val, op = op_tok.val:upper(), value = value }, pos + 3 | ||
elseif op_tok and op_tok.type == T.COMP then | |||
return { | |||
elseif op_tok | |||
local v = toks[pos + 2] | local v = toks[pos + 2] | ||
if v.type ~= T.STR and v.type ~= T.NUM and v.type ~= T.BOOL then | |||
if v.type ~= T.STR | error("Expected value after comparison operator at position " .. (pos + 2)) | ||
error( | |||
end | end | ||
return { type = 'comparison', field = toks[pos].val, op = op_tok.val, value = v.val }, pos + 3 | |||
return { | |||
else | else | ||
error( | error("Expected comparison or filter operator after identifier at position " .. (pos + 1)) | ||
end | end | ||
else | else | ||
error( | error("Unexpected token at position " .. pos .. ", expected identifier or '('") | ||
end | end | ||
end | end | ||
local function parseCond(toks, pos) | local function parseCond(toks, pos) | ||
local node, new_pos = | local node, new_pos = parseExpression(toks, pos) | ||
return node, new_pos | return node, new_pos | ||
end | end | ||
| Line 1,553: | Line 791: | ||
local function toBucket(n) | local function toBucket(n) | ||
if n.type == 'and' then | if n.type == 'and' then | ||
return Bucket.And( | return Bucket.And(toBucket(n.left), toBucket(n.right)) | ||
elseif n.type == 'or' then | elseif n.type == 'or' then | ||
return Bucket.Or( | return Bucket.Or(toBucket(n.left), toBucket(n.right)) | ||
elseif n.type == 'not' then | elseif n.type == 'not' then | ||
return Bucket.Not( | return Bucket.Not(toBucket(n.operand)) | ||
elseif n.type == 'comparison' then | elseif n.type == 'comparison' then | ||
return { n.field, n.op, n.value } | |||
return { | |||
end | end | ||
end | end | ||
local FILTER_FUNCS = { | local FILTER_FUNCS = { | ||
CONTAINS = function(v, s) | CONTAINS = function(v, s) return type(v) == 'string' and mw.ustring.find(v, s, 1, true) ~= nil end, | ||
ICONTAINS = function(v, s) | ICONTAINS = function(v, s) | ||
return type(v) == 'string' | return | ||
type(v) == 'string' and | |||
mw.ustring.find(mw.ustring.lower(v), mw.ustring.lower(s), 1, true) ~= nil | |||
end, | end, | ||
MATCH = function(v, s) return type(v) == 'string' and mw.ustring.match(v, s) ~= nil end, | |||
MATCH = function(v, s) | |||
} | } | ||
local COMP_FUNCS = { | local COMP_FUNCS = { | ||
['='] = function(a, b) | ['='] = function(a, b) return a == b end, | ||
['!='] = function(a, b) return a ~= b end, | |||
['<'] = function(a, b) return compareValues(a, b) == -1 end, | |||
['>'] = function(a, b) return compareValues(a, b) == 1 end, | |||
['<='] = function(a, b) return compareValues(a, b) <= 0 end, | |||
['>='] = function(a, b) return compareValues(a, b) >= 0 end, | |||
['!='] = function(a, b) | |||
['<'] = function(a, b) | |||
['>'] = function(a, b) | |||
['<='] = function(a, b) | |||
['>='] = function(a, b) | |||
} | } | ||
local function evalFilter(node, row) | local function evalFilter(node, row) | ||
if node.type == 'and' then | if node.type == 'and' then | ||
return evalFilter( | return evalFilter(node.left, row) and evalFilter(node.right, row) | ||
elseif node.type == 'or' then | elseif node.type == 'or' then | ||
return evalFilter( | return evalFilter(node.left, row) or evalFilter(node.right, row) | ||
elseif node.type == 'not' then | elseif node.type == 'not' then | ||
return not evalFilter( | return not evalFilter(node.operand, row) | ||
elseif node.type == 'filter_op' then | elseif node.type == 'filter_op' then | ||
local fn = | local fn = FILTER_FUNCS[node.op] | ||
if not fn then error('Unknown filter op: ' .. node.op) end | |||
return fn(row[node.field], node.value) | |||
if not fn then | |||
return fn( | |||
elseif node.type == 'comparison' then | elseif node.type == 'comparison' then | ||
local fn = | local fn = COMP_FUNCS[node.op] | ||
if not fn then error('Unknown comparison op: ' .. node.op) end | |||
return fn(row[node.field], node.value) | |||
if not fn then | |||
return fn( | |||
end | end | ||
end | end | ||
| Line 1,710: | Line 840: | ||
local function applyFilter(results, ast) | local function applyFilter(results, ast) | ||
local filtered = {} | local filtered = {} | ||
for _, row in ipairs(results) do | for _, row in ipairs(results) do | ||
if evalFilter(ast, row) then | if evalFilter(ast, row) then | ||
table.insert( | table.insert(filtered, row) | ||
end | end | ||
end | end | ||
return filtered | return filtered | ||
end | end | ||
local function runQuery(columns, args) | local function runQuery(columns, args) | ||
local bucket_name = | local bucket_name = mw.text.trim(args[1] or args['bucket'] or '') | ||
if bucket_name == '' then | if bucket_name == '' then | ||
error( | error('Must specify the name of the bucket') | ||
end | end | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
col.label = | col.label = args[col.alias .. '-label'] or col.alias | ||
end | end | ||
local all_originals = {} | local all_originals = {} | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
all_originals[col.column] = true | all_originals[col.column] = true | ||
end | end | ||
local alias_to_column = {} | local alias_to_column = {} | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
if col.alias ~= col.column | if col.alias ~= col.column and not all_originals[col.alias] then | ||
alias_to_column[col.alias] = col.column | |||
alias_to_column[col.alias] = | |||
end | end | ||
end | end | ||
local column_names = {} | local column_names = {} | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
table.insert( | table.insert(column_names, col.column) | ||
end | end | ||
local query = mw.bucket(bucket_name).select(unpack(column_names)) | |||
local join_str = args['join on'] or args['join'] | |||
local join_str = | |||
if join_str then | if join_str then | ||
local joins = | local joins = parseJoins(join_str) | ||
for _, join in ipairs(joins) do | for _, join in ipairs(joins) do | ||
join.left = | join.left = alias_to_column[join.left] or join.left | ||
join.right = alias_to_column[join.right] or join.right | |||
local right_table = join.right:match("^[^.]+") or join.right | |||
query = query.join(right_table, join.left, join.right) | |||
join.right = | |||
local right_table = | |||
query = | |||
end | end | ||
end | end | ||
local where_str = args['where'] | local where_str = args['where'] | ||
if where_str then | if where_str then | ||
local toks = | local toks = tokenize(where_str) | ||
local ast = parseCond(toks, 1) | |||
translateAliases(ast, alias_to_column) | |||
local ast = | query = query.where(toBucket(ast)) | ||
translateAliases( | |||
query = | |||
end | end | ||
local limit = | local limit = tonumber(args['limit']) | ||
if limit ~= nil then | if limit ~= nil then | ||
query = query.limit(limit) | query = query.limit(limit) | ||
end | end | ||
local offset = | local offset = tonumber(args['offset']) | ||
if offset ~= nil then | if offset ~= nil then | ||
query = query.offset(offset) | query = query.offset(offset) | ||
| Line 1,846: | Line 906: | ||
return query:run() | return query:run() | ||
end | end | ||
function p.get(frame) | function p.get(frame) | ||
local args = | local args = getArgs(frame) | ||
local columns_str = args['select'] or args['fields'] | |||
local columns_str = | |||
if not columns_str then | if not columns_str then | ||
error( | error("Must specify which columns to select.") | ||
end | end | ||
local columns = | local columns = parseColumns(columns_str) | ||
local | local results = runQuery(columns, args) | ||
-- mw.logObject(results) | |||
renameColumns(results, columns) | |||
local filter_str = args['filter'] | |||
if filter_str then | if filter_str then | ||
local toks = | local toks = tokenize(filter_str) | ||
local ast = parseCond(toks, 1) | |||
results = applyFilter(results, ast) | |||
local ast = | |||
results = | |||
end | end | ||
if #results == 0 then | if #results == 0 then | ||
return args['default'] | return args['default'] or "No result" | ||
end | end | ||
local group_by_str = | local group_by_str = args['group by'] | ||
if group_by_str then | if group_by_str then | ||
local group_by_cols = | local group_by_cols = parseGroupBy(group_by_str) | ||
local agg_specs = parseAggregateArg(args['aggregate'] or '', columns) | |||
local agg_specs = | |||
for _, spec in ipairs(agg_specs) do | for _, spec in ipairs(agg_specs) do | ||
if spec.is_new_col then | if spec.is_new_col then | ||
| Line 1,925: | Line 942: | ||
column = spec.output_col, | column = spec.output_col, | ||
alias = spec.output_col, | alias = spec.output_col, | ||
label = | label = args[spec.output_col .. '-label'] or spec.output_col | ||
} | } | ||
) | ) | ||
end | end | ||
end | end | ||
local group_sort = parseOrderBy(args['group-sort'] or '') | |||
local group_sort = | results = groupResults(results, group_by_cols, agg_specs, columns, group_sort, args) | ||
results = | |||
end | end | ||
applySplitColumns( | applySplitColumns(results, columns, args) | ||
local order_by_str = args['order by'] | |||
if order_by_str then | if order_by_str then | ||
local order_by_rules = | local order_by_rules = parseOrderBy(order_by_str) | ||
sortResults(results, order_by_rules) | |||
sortResults( | |||
end | end | ||
applyValueFormats( | -- FIXME: this inserts markup that may need to be preprocessed, but for performance reasons | ||
-- we don't want to preprocess them row-by-row. | |||
applyValueFormats(results, columns, args, frame) | |||
local format = args['format'] or "table" | |||
local prefix = "" | local prefix = "" | ||
local resultsHeader = args['resultsHeader'] or "'''%TOTALRESULTS%''' result%S%:" | |||
local resultsHeader = | local header = args['header'] or "" | ||
local footer = args['footer'] or "" | |||
local header = | |||
local footer = | |||
local text = "" | local text = "" | ||
local resultsCount = | local resultsCount = #results | ||
resultsHeader = resultsHeader | |||
:gsub("%%TOTALRESULTS%%", resultsCount) | |||
:gsub("%%ISARE%%", resultsCount == 1 and "is" or "are") | |||
:gsub("%%S%%", resultsCount == 1 and "" or "s") | |||
:gsub("%%ES%%", resultsCount == 1 and "" or "es") | |||
:gsub("%%HASHAVE%%", resultsCount == 1 and "has" or "have") | |||
local yesResultsHeader = args['show resultsHeader'] | |||
local yesResultsHeader = | |||
local showResultsHeader = false | local showResultsHeader = false | ||
if (args['resultsHeader'] ~= nil and args['resultsHeader'] ~= "") | |||
if | |||
or yesResultsHeader == true | or yesResultsHeader == true | ||
or ( | or (type(yesResultsHeader) == "string" and (yesResultsHeader:lower() == "true" or yesResultsHeader == "1")) then | ||
showResultsHeader = true | |||
end | end | ||
if showResultsHeader then | if showResultsHeader then | ||
prefix = resultsHeader .. "\n" | |||
end | end | ||
if format == "table" then | if format == "table" then | ||
text = | text = formatTable(frame, results, columns, args) | ||
elseif format == "template" then | elseif format == "template" then | ||
text = | text = formatTemplate(frame, results, columns, args) | ||
elseif format == "userformat" then | elseif format == "userformat" then | ||
text = | text = formatUserformat(frame, results, columns, args) | ||
else | else | ||
error( | error(string.format("Invalid format %s specified.", format)) | ||
end | end | ||
return | return prefix .. header .. text .. footer | ||
end | end | ||
-- Exports for testing | -- Exports for testing | ||
p.sortResults = sortResults | p.sortResults = sortResults | ||
p.aggCount = aggCount | p.aggCount = aggCount | ||
| Line 2,122: | Line 1,010: | ||
p.parseAggregateArg = parseAggregateArg | p.parseAggregateArg = parseAggregateArg | ||
p.T = T | p.T = T | ||
p.tokenize = tokenize | p.tokenize = tokenize | ||
p.parseCond = parseCond | p.parseCond = parseCond | ||