Module:BucketGet: Difference between revisions
Appearance
mNo edit summary |
ReisuDesign (talk | contribs) No edit summary |
||
| Line 6: | Line 6: | ||
local NULL = {} | local NULL = {} | ||
local TYPE_ORDER = { ["nil"] = 1, number = 2, string = 3, boolean = 4, table = 5 } | local TYPE_ORDER = { | ||
["nil"] = 1, | |||
number = 2, | |||
string = 3, | |||
boolean = 4, | |||
table = 5 | |||
} | |||
-------------------------------------------------------------------------------- | |||
-- Comparison / sorting | |||
-------------------------------------------------------------------------------- | |||
local function compareValues(a, b) | local function compareValues(a, b) | ||
| Line 27: | Line 37: | ||
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 return -1 else return 1 end | if a < b then | ||
return -1 | |||
else | |||
return 1 | |||
end | |||
elseif a_type == 3 then | elseif a_type == 3 then | ||
if a < b then return -1 else return 1 end | if a < b then | ||
return -1 | |||
else | |||
return 1 | |||
end | |||
elseif a_type == 4 then | elseif a_type == 4 then | ||
if | 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 62: | Line 91: | ||
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 70: | Line 100: | ||
end | end | ||
end | end | ||
return false | return false | ||
end) | end) | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Table formatting | |||
-------------------------------------------------------------------------------- | |||
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 = part:match('^%s*(.-)%s*=%s*(.-)%s*$') | 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 106: | Line 153: | ||
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 113: | Line 162: | ||
data = tostring(data) | data = tostring(data) | ||
end | end | ||
tr:tag('td'):wikitext(data) | tr:tag('td'):wikitext(data) | ||
end | end | ||
end | end | ||
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 | ||
-------------------------------------------------------------------------------- | |||
-- Delimiters | |||
-------------------------------------------------------------------------------- | |||
local function getDelimiter(args) | local function getDelimiter(args) | ||
| Line 129: | Line 182: | ||
return delimiter:gsub("\\n", "\n") | return delimiter:gsub("\\n", "\n") | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Template formatting | |||
-------------------------------------------------------------------------------- | |||
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( | |||
parts, | |||
("|%s=%s"):format(col.label, value) | |||
) | |||
end | end | ||
| Line 157: | Line 225: | ||
end | end | ||
return frame:preprocess(table.concat(output, delimiter)) | return frame:preprocess( | ||
table.concat(output, delimiter) | |||
) | |||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Column parsing | |||
-------------------------------------------------------------------------------- | |||
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 | ||
local 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 = col:lower():find("%sas%s") | local as_pos, as_end = | ||
col:lower():find("%sas%s") | |||
if eq_pos then | if eq_pos then | ||
column = col:sub(1, eq_pos - 1):match("^%s*(.-)%s*$") | column = | ||
alias = col:sub(eq_pos + 1):match("^%s*(.-)%s*$") | col:sub(1, eq_pos - 1):match("^%s*(.-)%s*$") | ||
alias = | |||
col:sub(eq_pos + 1):match("^%s*(.-)%s*$") | |||
elseif as_pos then | elseif as_pos then | ||
column = col:sub(1, as_pos - 1):match("^%s*(.-)%s*$") | column = | ||
alias = col:sub(as_end + 1):match("^%s*(.-)%s*$") | col:sub(1, as_pos - 1):match("^%s*(.-)%s*$") | ||
alias = | |||
col:sub(as_end + 1):match("^%s*(.-)%s*$") | |||
else | else | ||
column = col | column = col | ||
| Line 180: | Line 266: | ||
end | end | ||
table.insert(columns, { column = column, alias = alias }) | table.insert(columns, { | ||
column = column, | |||
alias = alias | |||
}) | |||
end | end | ||
return columns | return columns | ||
end | end | ||
-- | -------------------------------------------------------------------------------- | ||
-- Column aliases | |||
-------------------------------------------------------------------------------- | |||
local function renameColumns(results, columns) | local function renameColumns(results, columns) | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
| Line 196: | Line 289: | ||
end | end | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Split columns | |||
-------------------------------------------------------------------------------- | |||
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("Invalid %s-split value '%s': must be 'auto' or a positive integer", col.alias, val)) | 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, { | |||
alias = col.alias, | |||
count = count | |||
}) | |||
end | end | ||
end | end | ||
| Line 219: | Line 329: | ||
for _, spec in ipairs(split_specs) do | for _, spec in ipairs(split_specs) do | ||
-- | |||
-- Resolve "auto" counts. | |||
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 the split columns. | |||
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 243: | Line 359: | ||
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 250: | Line 368: | ||
val = (i == 1) and v | val = (i == 1) and v | ||
end | end | ||
row[spec.alias .. i] = val or "" | row[spec.alias .. i] = val or "" | ||
end | end | ||
| Line 255: | Line 374: | ||
end | end | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Alias translation | |||
-------------------------------------------------------------------------------- | |||
local function translateAliases(node, alias_to_column) | local function translateAliases(node, alias_to_column) | ||
| Line 260: | Line 383: | ||
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' | |||
or node.type == 'filter_op' then | |||
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 269: | Line 397: | ||
end | end | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Order parsing | |||
-------------------------------------------------------------------------------- | |||
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 | |||
local column | |||
local direction | |||
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 = mw.text.trim(col:sub(1, space_pos - 1)) | column = | ||
local dir = mw.text.trim(col:sub(space_pos + 1)):lower() | mw.text.trim( | ||
col:sub(1, space_pos - 1) | |||
) | |||
local dir = | |||
mw.text.trim( | |||
col:sub(space_pos + 1) | |||
):lower() | |||
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(("Sort direction must be either ASC or DESC. %s provided."):format(dir)) | error( | ||
("Sort direction must be either ASC or DESC. %s provided.") | |||
:format(dir) | |||
) | |||
end | end | ||
else | else | ||
column = col | column = col | ||
| Line 293: | Line 443: | ||
end | end | ||
table.insert(order_by, { column = column, direction = direction }) | table.insert(order_by, { | ||
column = column, | |||
direction = direction | |||
}) | |||
end | end | ||
return order_by | return order_by | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Unescape | |||
-------------------------------------------------------------------------------- | |||
local function unescape(text) | local function unescape(text) | ||
| Line 302: | Line 460: | ||
return text | return text | ||
end | end | ||
local unescaped = mw.text.unstripNoWiki(text) | local unescaped = mw.text.unstripNoWiki(text) | ||
local text, count = string.gsub(unescaped, "<", "<") | |||
text, count = string.gsub(text, ">", ">") | local text, count = | ||
text, count = string.gsub(text, "<//nowiki>", "</nowiki>") | string.gsub(unescaped, "<", "<") | ||
text, count = | |||
string.gsub(text, ">", ">") | |||
text, count = | |||
string.gsub(text, "<//nowiki>", "</nowiki>") | |||
return text | return text | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Userformat value normalization | |||
-------------------------------------------------------------------------------- | |||
local function normalizeUserformatValue(value) | |||
-- Missing values. | |||
if value == nil then | |||
return '' | |||
end | |||
-- Boolean false should not become "false". | |||
if value == false then | |||
return '' | |||
end | |||
-- Arrays. | |||
if type(value) == "table" then | |||
local values = {} | |||
for _, v in ipairs(value) do | |||
if v ~= nil and v ~= false then | |||
local s = mw.text.trim(tostring(v)) | |||
if s ~= '' then | |||
table.insert(values, s) | |||
end | |||
end | |||
end | |||
return table.concat(values, '; ') | |||
end | |||
-- Normal scalar value. | |||
value = mw.text.trim(tostring(value)) | |||
-- Treat common null representations as empty. | |||
local lower = mw.ustring.lower(value) | |||
if lower == '' | |||
or lower == 'null' | |||
or lower == 'nil' | |||
or lower == 'false' then | |||
return '' | |||
end | |||
-- Remove non-breaking spaces if a bucket value happens | |||
-- to contain them. | |||
value = value:gsub('\194\160', ' ') | |||
return mw.text.trim(value) | |||
end | |||
-------------------------------------------------------------------------------- | |||
-- Userformat | |||
-------------------------------------------------------------------------------- | |||
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 delimiter = getDelimiter(args) | ||
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 = row[col.alias] | local value = | ||
normalizeUserformatValue(row[col.alias]) | |||
local placeholder = | |||
"%%" .. col.label .. "%%" | |||
formatted = | |||
formatted:gsub( | |||
local placeholder = "%%" .. col.label .. "%%" | placeholder, | ||
formatted = formatted:gsub(placeholder, function() return value end) | function() | ||
return value | |||
end | |||
) | |||
end | end | ||
table.insert(output, formatted) | table.insert(output, formatted) | ||
end | end | ||
return frame:preprocess(table.concat(output, delimiter)) | return frame:preprocess( | ||
table.concat(output, delimiter) | |||
) | |||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Grouping | |||
-------------------------------------------------------------------------------- | |||
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 | ||
-------------------------------------------------------------------------------- | |||
-- Aggregation parsing | |||
-------------------------------------------------------------------------------- | |||
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 | |||
or aggregate_str:match('^%s*$') then | |||
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:match('^%s*(.-)%s*$') | spec_str = | ||
spec_str:match('^%s*(.-)%s*$') | |||
local output_col | |||
local main_part | |||
local as_pos, as_end = | |||
spec_str:lower():find('%s+as%s+') | |||
if as_pos then | if as_pos then | ||
main_part = spec_str:sub(1, as_pos - 1):match('^%s*(.-)%s*$') | main_part = | ||
output_col = spec_str:sub(as_end + 1):match('^%s*(.-)%s*$') | spec_str:sub(1, as_pos - 1) | ||
:match('^%s*(.-)%s*$') | |||
output_col = | |||
spec_str:sub(as_end + 1) | |||
:match('^%s*(.-)%s*$') | |||
else | else | ||
main_part = spec_str | main_part = spec_str | ||
| Line 367: | Line 623: | ||
end | end | ||
local func_name, args_str = | |||
local func_name, args_str = main_part:match('^(%w+)%s*%((.*)%)%s*$') | 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 381: | Line 642: | ||
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 394: | Line 656: | ||
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 = main_part:match('^%s*(.-)%s*$') | 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 408: | Line 676: | ||
end | end | ||
end | end | ||
table.insert(agg_specs, { | table.insert(agg_specs, { | ||
func = 'TABLE', | func = 'TABLE', | ||
| Line 417: | Line 686: | ||
end | end | ||
end | end | ||
return agg_specs | return agg_specs | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Aggregation functions | |||
-------------------------------------------------------------------------------- | |||
local function aggCount(rows, column) | local function aggCount(rows, column) | ||
| Line 426: | Line 700: | ||
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 438: | Line 714: | ||
end | end | ||
end | end | ||
return result | return result | ||
end | end | ||
| Line 443: | Line 720: | ||
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 | |||
min_val = v | |||
end | |||
end | end | ||
return min_val | return min_val | ||
end | end | ||
| Line 452: | Line 734: | ||
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 | |||
max_val = v | |||
end | |||
end | end | ||
return max_val | return max_val | ||
end | end | ||
| Line 462: | Line 749: | ||
return rows[1][column] | return rows[1][column] | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Value formats | |||
-------------------------------------------------------------------------------- | |||
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 = { page_name = true, page_name_sub = true } | local link_columns = { | ||
page_name = true, | |||
page_name_sub = true | |||
} | |||
local table_name = args[1] | local table_name = args[1] | ||
local data = mw.loadJsonData("Bucket:" .. table_name) | 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 477: | Line 773: | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
local format_key = col.alias .. "-format" | local format_key = | ||
if args[format_key] or type(results[1][col.alias]) == "table" then | col.alias .. "-format" | ||
local formatter = args[format_key] or (link_columns[col.column] and "[[%s]]" or "%s") | |||
local delimiter = args[col.alias .. "-delimiter"] or "; " | if args[format_key] | ||
delimiter = delimiter:gsub("\\n", "\n") | or type(results[1][col.alias]) == "table" then | ||
local formatter = | |||
args[format_key] | |||
or ( | |||
link_columns[col.column] | |||
and "[[%s]]" | |||
or "%s" | |||
) | |||
local delimiter = | |||
args[col.alias .. "-delimiter"] | |||
or "; " | |||
delimiter = | |||
delimiter:gsub("\\n", "\n") | |||
table.insert(formats_to_apply, { | table.insert(formats_to_apply, { | ||
| Line 498: | Line 809: | ||
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 | if value == nil then | ||
value = '' | value = '' | ||
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) | local formatted = | ||
table.insert(formatted_values, 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] = | |||
table.concat( | |||
formatted_values, | |||
fmt_info.delimiter | |||
) | |||
else | else | ||
local formatted = fmt_info.format:gsub("%%s", function() return value end) | local formatted = | ||
fmt_info.format:gsub( | |||
"%%s", | |||
function() | |||
return value | |||
end | |||
) | |||
row[fmt_info.column] = formatted | row[fmt_info.column] = formatted | ||
end | end | ||
| Line 516: | Line 853: | ||
return results | return results | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Group building | |||
-------------------------------------------------------------------------------- | |||
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(key_parts, table.concat(value, '\0')) | 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(key_parts, tostring(value)) | table.insert( | ||
key_parts, | |||
tostring(value) | |||
) | |||
end | end | ||
end | end | ||
local key = table.concat(key_parts, '\1') | |||
local key = | |||
table.concat(key_parts, '\1') | |||
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 | ||
-------------------------------------------------------------------------------- | |||
-- Aggregation | |||
-------------------------------------------------------------------------------- | |||
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] = aggCount(group_rows, spec.args[1]) | 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] = aggTable(group_rows, spec.args[1]) | 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] = aggMin(group_rows, spec.args[1]) | 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] = aggMax(group_rows, spec.args[1]) | 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] = aggAny(group_rows, spec.args[1]) | new_row[spec.output_col] = | ||
aggAny( | |||
group_rows, | |||
spec.args[1] | |||
) | |||
end | end | ||
end | end | ||
return format_specs | return format_specs | ||
end | end | ||
| Line 564: | Line 953: | ||
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 573: | Line 963: | ||
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 = tostring(val[i] ~= nil and val[i] or '') | 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 = | |||
result_str:gsub( | |||
'%%' .. arg_col .. '%%', | |||
function() | |||
return sv | |||
end | |||
) | |||
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 = | |||
result_str:gsub( | |||
'%%' .. arg_col .. '%%', | |||
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(results, group_by_cols, agg_specs, columns, group_sort, args) | 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 607: | Line 1,034: | ||
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 612: | Line 1,040: | ||
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 = buildGroups(results, group_by_cols) | 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(group_rows, group_sort) | 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] = group_rows[1][col] | new_row[col] = | ||
group_rows[1][col] | |||
end | end | ||
local format_specs = applyAggregation(group_rows, agg_specs, new_row) | 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 | |||
new_row[col_name] = aggAny(group_rows, col_name) | if not group_by_set[col_name] | ||
and not covered[col_name] then | |||
new_row[col_name] = | |||
aggAny( | |||
group_rows, | |||
col_name | |||
) | |||
end | end | ||
end | end | ||
for _, spec in ipairs(format_specs) do | for _, spec in ipairs(format_specs) do | ||
local fmt_template = args[spec.output_col .. '-agg'] | local fmt_template = | ||
args[spec.output_col .. '-agg'] | |||
if fmt_template then | if fmt_template then | ||
applyFormatSpec(new_row, spec, unescape(fmt_template)) | applyFormatSpec( | ||
new_row, | |||
spec, | |||
unescape(fmt_template) | |||
) | |||
end | end | ||
end | end | ||
table.insert(grouped_results, new_row) | 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, NULL = 6, COMP = 7, STR = 8, NUM = 9, BOOL = 10, ID = 11, EOF = 12 } | -------------------------------------------------------------------------------- | ||
-- Filter parser | |||
-------------------------------------------------------------------------------- | |||
local T = { | |||
PARENTHESIS_L = 1, | |||
PARENTHESIS_R = 2, | |||
AND = 3, | |||
OR = 4, | |||
NOT = 5, | |||
NULL = 6, | |||
COMP = 7, | |||
STR = 8, | |||
NUM = 9, | |||
BOOL = 10, | |||
ID = 11, | |||
EOF = 12 | |||
} | |||
local KW = { | local KW = { | ||
['AND'] = T.AND, | ['AND'] = T.AND, | ||
| Line 658: | Line 1,138: | ||
['true'] = T.BOOL, | ['true'] = T.BOOL, | ||
['false'] = T.BOOL, | ['false'] = T.BOOL, | ||
['NULL'] = T.NULL | ['NULL'] = T.NULL | ||
} | } | ||
local FILTER_OPS = { CONTAINS = true, ICONTAINS = true, MATCH = true } | |||
local FILTER_OPS = { | |||
CONTAINS = true, | |||
ICONTAINS = true, | |||
MATCH = true | |||
} | |||
-------------------------------------------------------------------------------- | |||
-- Joins | |||
-------------------------------------------------------------------------------- | |||
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(parts, mw.text.trim(part)) | 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( | |||
joins, | |||
join | |||
) | |||
end | end | ||
end | end | ||
return joins | return joins | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Tokenizer | |||
-------------------------------------------------------------------------------- | |||
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(t, { type = T.PARENTHESIS_L }) | table.insert( | ||
t, | |||
{ type = T.PARENTHESIS_L } | |||
) | |||
i = i + 1 | |||
elseif c == ')' then | elseif c == ')' then | ||
table.insert(t, { type = T.PARENTHESIS_R }) | 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 = s:match('^%-?%d*%.?%d+', i) | local n = | ||
table.insert(t, { type = T.NUM, val = tonumber(n) }) | s:match( | ||
'^%-?%d*%.?%d+', | |||
i | |||
) | |||
table.insert( | |||
t, | |||
{ | |||
type = T.NUM, | |||
val = tonumber(n) | |||
} | |||
) | |||
i = i + #n | i = i + #n | ||
elseif c:match("[%w_]") then | elseif c:match("[%w_]") then | ||
local w = s:match('^[%w_.]+', i) | local w = | ||
table.insert(t, { type = KW[w:upper()] or KW[w] or T.ID, val = w }) | s:match( | ||
'^[%w_.]+', | |||
i | |||
) | |||
table.insert( | |||
t, | |||
{ | |||
type = | |||
KW[w:upper()] | |||
or KW[w] | |||
or T.ID, | |||
val = w | |||
} | |||
) | |||
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(t, { type = T.STR, val = str }) | 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('[=<>!]') then | elseif c:match('[=<>!]') then | ||
local op = s:match('[=<>!]+', i) | local op = | ||
s:match( | |||
'[=<>!]+', | |||
i | |||
) | |||
if not op then | if not op then | ||
error("Invalid operator at position " .. i) | error( | ||
"Invalid operator at position " | |||
.. i | |||
) | |||
end | end | ||
table.insert(t, { type = T.COMP, val = op }) | |||
table.insert( | |||
t, | |||
{ | |||
type = T.COMP, | |||
val = op | |||
} | |||
) | |||
i = i + #op | i = i + #op | ||
else | else | ||
error("Unexpected character '" .. c .. "' at position " .. i) | error( | ||
"Unexpected character '" | |||
.. c | |||
.. "' at position " | |||
.. i | |||
) | |||
end | end | ||
end | end | ||
return t | return t | ||
end | end | ||
-- | -------------------------------------------------------------------------------- | ||
-- Expression parser | |||
-------------------------------------------------------------------------------- | |||
local parsePrimary | local parsePrimary | ||
local function parseNotExpr(toks, pos) | local function parseNotExpr(toks, pos) | ||
if toks[pos] and toks[pos].type == T.NOT then | if toks[pos] | ||
local node, new_pos = parseNotExpr(toks, pos + 1) | and toks[pos].type == T.NOT then | ||
return { type = 'not', operand = node }, new_pos | |||
local node, new_pos = | |||
parseNotExpr( | |||
toks, | |||
pos + 1 | |||
) | |||
return { | |||
type = 'not', | |||
operand = node | |||
}, new_pos | |||
else | else | ||
return parsePrimary(toks, pos) | return parsePrimary( | ||
toks, | |||
pos | |||
) | |||
end | end | ||
end | end | ||
local function parseAndExpr(toks, pos) | local function parseAndExpr(toks, pos) | ||
local left, new_pos = parseNotExpr(toks, pos) | local left, new_pos = | ||
while new_pos <= #toks and toks[new_pos].type == T.AND do | parseNotExpr( | ||
local right, next_pos = parseNotExpr(toks, new_pos + 1) | toks, | ||
left = { type = 'and', left = left, right = right } | 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 | |||
} | |||
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 = parseAndExpr(toks, pos) | local left, new_pos = | ||
while new_pos <= #toks and toks[new_pos].type == T.OR do | parseAndExpr( | ||
local right, next_pos = parseAndExpr(toks, new_pos + 1) | toks, | ||
left = { type = 'or', left = left, right = right } | 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 | |||
} | |||
new_pos = next_pos | new_pos = next_pos | ||
end | end | ||
return left, new_pos | return left, new_pos | ||
end | end | ||
| Line 758: | Line 1,391: | ||
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 = parseExpression(toks, pos + 1) | local node, new_pos = | ||
if toks[new_pos].type ~= T.PARENTHESIS_R then | parseExpression( | ||
error("Expected ')' at position " .. new_pos) | toks, | ||
pos + 1 | |||
) | |||
if toks[new_pos].type | |||
~= T.PARENTHESIS_R then | |||
error( | |||
"Expected ')' at position " | |||
.. new_pos | |||
) | |||
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 | |||
and op_tok.type == T.ID | |||
and FILTER_OPS[ | |||
op_tok.val:upper() | |||
] then | |||
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 | |||
error("Expected value after filter operator at position " .. (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 | |||
error( | |||
"Expected value after filter operator at position " | |||
.. (pos + 2) | |||
) | |||
end | end | ||
local value = (v.type == T.NUM) and v.val or tostring(v.val) | |||
return { type = 'filter_op', field = toks[pos].val, op = op_tok.val:upper(), value = value }, pos + 3 | local value = | ||
elseif op_tok and op_tok.type == T.COMP then | (v.type == T.NUM) | ||
and v.val | |||
or tostring(v.val) | |||
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 | |||
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 and v.type ~= T.NULL then | |||
error("Expected value after comparison operator at position " .. (pos + 2)) | if v.type ~= T.STR | ||
and v.type ~= T.NUM | |||
and v.type ~= T.BOOL | |||
and v.type ~= T.NULL then | |||
error( | |||
"Expected value after comparison operator at position " | |||
.. (pos + 2) | |||
) | |||
end | end | ||
local value | local value | ||
if v.type == T.NULL then | if v.type == T.NULL then | ||
value = NULL | value = NULL | ||
elseif v.type == T.BOOL then | elseif v.type == T.BOOL then | ||
value = (v.val == 'true') | value = (v.val == 'true') | ||
else | else | ||
value = v.val | value = v.val | ||
end | end | ||
return { type = 'comparison', field = toks[pos].val, op = op_tok.val, value = value }, pos + 3 | |||
return { | |||
type = 'comparison', | |||
field = toks[pos].val, | |||
op = op_tok.val, | |||
value = value | |||
}, pos + 3 | |||
else | else | ||
error("Expected comparison or filter operator after identifier at position " .. (pos + 1)) | error( | ||
"Expected comparison or filter operator after identifier at position " | |||
.. (pos + 1) | |||
) | |||
end | end | ||
else | else | ||
error("Unexpected token at position " .. pos .. ", expected identifier or '('") | 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 = parseExpression(toks, pos) | local node, new_pos = | ||
parseExpression( | |||
toks, | |||
pos | |||
) | |||
return node, new_pos | return node, new_pos | ||
end | end | ||
| Line 801: | Line 1,508: | ||
local function toBucket(n) | local function toBucket(n) | ||
if n.type == 'and' then | if n.type == 'and' then | ||
return Bucket.And(toBucket(n.left), toBucket(n.right)) | return Bucket.And( | ||
toBucket(n.left), | |||
toBucket(n.right) | |||
) | |||
elseif n.type == 'or' then | elseif n.type == 'or' then | ||
return Bucket.Or(toBucket(n.left), toBucket(n.right)) | return Bucket.Or( | ||
toBucket(n.left), | |||
toBucket(n.right) | |||
) | |||
elseif n.type == 'not' then | elseif n.type == 'not' then | ||
return Bucket.Not(toBucket(n.operand)) | return Bucket.Not( | ||
toBucket(n.operand) | |||
) | |||
elseif n.type == 'comparison' then | elseif n.type == 'comparison' then | ||
local val = (n.value == NULL) and Bucket.Null() or n.value | local val = | ||
return { n.field, n.op, val } | (n.value == NULL) | ||
and Bucket.Null() | |||
or n.value | |||
return { | |||
n.field, | |||
n.op, | |||
val | |||
} | |||
end | end | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Filter evaluation | |||
-------------------------------------------------------------------------------- | |||
local FILTER_FUNCS = { | local FILTER_FUNCS = { | ||
CONTAINS | 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 | return type(v) == 'string' | ||
and mw.ustring.find( | |||
mw.ustring.find(mw.ustring.lower(v), mw.ustring.lower(s), 1, true) ~= nil | mw.ustring.lower(v), | ||
mw.ustring.lower(s), | |||
1, | |||
true | |||
) ~= nil | |||
end, | end, | ||
MATCH | |||
MATCH = function(v, s) | |||
return type(v) == 'string' | |||
and mw.ustring.match(v, s) | |||
~= nil | |||
end | |||
} | } | ||
local COMP_FUNCS = { | local COMP_FUNCS = { | ||
['='] | ['='] = function(a, b) | ||
['!='] = function(a, b) if b == NULL then return a ~= nil end | if b == NULL then | ||
['<'] | return a == nil | ||
['>'] | end | ||
['<='] = function(a, b) return compareValues(a, b) <= 0 end, | |||
['>='] = function(a, b) return compareValues(a, b) >= 0 end | return a == b | ||
end, | |||
['!='] = function(a, b) | |||
if b == NULL then | |||
return a ~= nil | |||
end | |||
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 | |||
} | } | ||
local function evalFilter(node, row) | local function evalFilter(node, row) | ||
if node.type == 'and' then | if node.type == 'and' then | ||
return evalFilter(node.left, row) and evalFilter(node.right, row) | return evalFilter( | ||
node.left, | |||
row | |||
) and evalFilter( | |||
node.right, | |||
row | |||
) | |||
elseif node.type == 'or' then | elseif node.type == 'or' then | ||
return evalFilter(node.left, row) or evalFilter(node.right, row) | return evalFilter( | ||
node.left, | |||
row | |||
) or evalFilter( | |||
node.right, | |||
row | |||
) | |||
elseif node.type == 'not' then | elseif node.type == 'not' then | ||
return not evalFilter(node.operand, row) | return not evalFilter( | ||
node.operand, | |||
row | |||
) | |||
elseif node.type == 'filter_op' then | elseif node.type == 'filter_op' then | ||
local fn = FILTER_FUNCS[node.op] | local fn = | ||
if not fn then error('Unknown filter op: ' .. node.op) end | FILTER_FUNCS[node.op] | ||
return fn(row[node.field], node.value) | |||
if not fn then | |||
error( | |||
'Unknown filter op: ' | |||
.. node.op | |||
) | |||
end | |||
return fn( | |||
row[node.field], | |||
node.value | |||
) | |||
elseif node.type == 'comparison' then | elseif node.type == 'comparison' then | ||
local fn = COMP_FUNCS[node.op] | local fn = | ||
if not fn then error('Unknown comparison op: ' .. node.op) end | COMP_FUNCS[node.op] | ||
return fn(row[node.field], node.value) | |||
if not fn then | |||
error( | |||
'Unknown comparison op: ' | |||
.. node.op | |||
) | |||
end | |||
return fn( | |||
row[node.field], | |||
node.value | |||
) | |||
end | end | ||
end | end | ||
| Line 851: | Line 1,665: | ||
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(filtered, row) | table.insert( | ||
filtered, | |||
row | |||
) | |||
end | end | ||
end | end | ||
return filtered | return filtered | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Query | |||
-------------------------------------------------------------------------------- | |||
local function runQuery(columns, args) | local function runQuery(columns, args) | ||
local bucket_name = mw.text.trim(args[1] or args['bucket'] or '') | local bucket_name = | ||
mw.text.trim( | |||
args[1] | |||
or args['bucket'] | |||
or '' | |||
) | |||
if bucket_name == '' then | if bucket_name == '' then | ||
error('Must specify the name of the bucket') | error( | ||
'Must specify the name of the bucket' | |||
) | |||
end | end | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
col.label = args[col.alias .. '-label'] or col.alias | 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 and not all_originals[col.alias] then | if col.alias ~= col.column | ||
alias_to_column[col.alias] = col.column | and not all_originals[col.alias] then | ||
alias_to_column[col.alias] = | |||
col.column | |||
end | end | ||
end | end | ||
local column_names = {} | local column_names = {} | ||
for _, col in ipairs(columns) do | for _, col in ipairs(columns) do | ||
table.insert(column_names, col.column) | table.insert( | ||
column_names, | |||
col.column | |||
) | |||
end | end | ||
local join_str = args['join_on'] or args['join'] | local query = | ||
mw.bucket(bucket_name) | |||
.select(unpack(column_names)) | |||
local join_str = | |||
args['join_on'] | |||
or args['join'] | |||
if join_str then | if join_str then | ||
local joins = parseJoins(join_str) | local joins = | ||
parseJoins(join_str) | |||
for _, join in ipairs(joins) do | for _, join in ipairs(joins) do | ||
join.left = alias_to_column[join.left] or join.left | join.left = | ||
join.right = alias_to_column[join.right] or join.right | alias_to_column[join.left] | ||
local right_table = join.right:match("^[^.]+") or join.right | or join.left | ||
query = query.join(right_table, join.left, join.right) | |||
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 | |||
) | |||
end | end | ||
end | end | ||
local where_str = args['where'] | local where_str = args['where'] | ||
if where_str then | if where_str then | ||
local toks = tokenize(where_str) | local toks = | ||
local ast = parseCond(toks, 1) | tokenize(where_str) | ||
translateAliases(ast, alias_to_column) | |||
query = query.where(toBucket(ast)) | local ast = | ||
parseCond( | |||
toks, | |||
1 | |||
) | |||
translateAliases( | |||
ast, | |||
alias_to_column | |||
) | |||
query = | |||
query.where( | |||
toBucket(ast) | |||
) | |||
end | end | ||
local limit = tonumber(args['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 = tonumber(args['offset']) | local offset = | ||
tonumber(args['offset']) | |||
if offset ~= nil then | if offset ~= nil then | ||
query = query.offset(offset) | query = query.offset(offset) | ||
| Line 917: | Line 1,801: | ||
return query:run() | return query:run() | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Main | |||
-------------------------------------------------------------------------------- | |||
function p.get(frame) | function p.get(frame) | ||
local args = getArgs(frame) | local args = | ||
local columns_str = args['select'] or args['fields'] | getArgs(frame) | ||
local columns_str = | |||
args['select'] | |||
or args['fields'] | |||
if not columns_str then | if not columns_str then | ||
error("Must specify which columns to select.") | error( | ||
"Must specify which columns to select." | |||
) | |||
end | end | ||
local columns = parseColumns(columns_str) | local columns = | ||
parseColumns(columns_str) | |||
local results = | |||
runQuery( | |||
columns, | |||
args | |||
) | |||
renameColumns( | |||
results, | |||
columns | |||
) | |||
local | local filter_str = | ||
args['filter'] | |||
if filter_str then | if filter_str then | ||
local toks = tokenize(filter_str) | local toks = | ||
local ast = parseCond(toks, 1) | tokenize(filter_str) | ||
results = applyFilter(results, ast) | |||
local ast = | |||
parseCond( | |||
toks, | |||
1 | |||
) | |||
results = | |||
applyFilter( | |||
results, | |||
ast | |||
) | |||
end | end | ||
if #results == 0 then | if #results == 0 then | ||
return args['default'] or "No result" | return args['default'] | ||
or "No result" | |||
end | end | ||
local group_by_str = args['group_by'] | local group_by_str = | ||
args['group_by'] | |||
if group_by_str then | if group_by_str then | ||
local group_by_cols = parseGroupBy(group_by_str) | local group_by_cols = | ||
local agg_specs = parseAggregateArg(args['aggregate'] or '', columns) | parseGroupBy( | ||
group_by_str | |||
) | |||
local agg_specs = | |||
parseAggregateArg( | |||
args['aggregate'] | |||
or '', | |||
columns | |||
) | |||
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 953: | Line 1,882: | ||
column = spec.output_col, | column = spec.output_col, | ||
alias = spec.output_col, | alias = spec.output_col, | ||
label = args[spec.output_col .. '-label'] or spec.output_col | label = | ||
args[ | |||
spec.output_col | |||
.. '-label' | |||
] | |||
or spec.output_col | |||
} | } | ||
) | ) | ||
end | end | ||
end | end | ||
local group_sort = parseOrderBy(args['group-sort'] or '') | |||
results = groupResults(results, group_by_cols, agg_specs, columns, group_sort, args) | local group_sort = | ||
parseOrderBy( | |||
args['group-sort'] | |||
or '' | |||
) | |||
results = | |||
groupResults( | |||
results, | |||
group_by_cols, | |||
agg_specs, | |||
columns, | |||
group_sort, | |||
args | |||
) | |||
end | end | ||
applySplitColumns(results, columns, args) | applySplitColumns( | ||
results, | |||
columns, | |||
args | |||
) | |||
local order_by_str = | |||
args['order_by'] | |||
or args['order'] | |||
if order_by_str then | if order_by_str then | ||
local order_by_rules = parseOrderBy(order_by_str) | local order_by_rules = | ||
sortResults(results, order_by_rules) | parseOrderBy( | ||
order_by_str | |||
) | |||
sortResults( | |||
results, | |||
order_by_rules | |||
) | |||
end | end | ||
applyValueFormats( | |||
results, | |||
applyValueFormats(results, columns, args, frame) | columns, | ||
args, | |||
frame | |||
) | |||
local format = | |||
args['format'] | |||
or "table" | |||
local prefix = "" | local prefix = "" | ||
local resultsHeader = args['results_header'] or "'''%TOTALRESULTS%''' result%S%:" | |||
local header = args['header'] or "" | local resultsHeader = | ||
local footer = args['footer'] or "" | args['results_header'] | ||
or "'''%TOTALRESULTS%''' result%S%:" | |||
local header = | |||
args['header'] | |||
or "" | |||
local footer = | |||
args['footer'] | |||
or "" | |||
local text = "" | local text = "" | ||
local resultsCount = #results | local resultsCount = | ||
#results | |||
resultsHeader = | |||
resultsHeader | |||
:gsub( | |||
"%%TOTALRESULTS%%", | |||
resultsCount | |||
local yesResultsHeader = args['show_results_header'] | ) | ||
: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_results_header'] | |||
local showResultsHeader = false | local showResultsHeader = false | ||
if (args['results_header'] ~= nil and args['results_header'] ~= "") | |||
if | |||
( | |||
args['results_header'] | |||
~= nil | |||
and args['results_header'] | |||
~= "" | |||
) | |||
or yesResultsHeader == true | or yesResultsHeader == true | ||
or (type(yesResultsHeader) == "string" and (yesResultsHeader:lower() == "true" or yesResultsHeader == "1")) then | 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 = formatTable(frame, results, columns, args) | text = | ||
formatTable( | |||
frame, | |||
results, | |||
columns, | |||
args | |||
) | |||
elseif format == "template" then | elseif format == "template" then | ||
text = formatTemplate(frame, results, columns, args) | text = | ||
formatTemplate( | |||
frame, | |||
results, | |||
columns, | |||
args | |||
) | |||
elseif format == "userformat" then | elseif format == "userformat" then | ||
text = formatUserformat(frame, results, columns, args) | text = | ||
formatUserformat( | |||
frame, | |||
results, | |||
columns, | |||
args | |||
) | |||
else | else | ||
error(string.format("Invalid format %s specified.", format)) | error( | ||
string.format( | |||
"Invalid format %s specified.", | |||
format | |||
) | |||
) | |||
end | end | ||
return prefix .. header .. text .. footer | 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 | ||
Revision as of 00:56, 28 September 2026
| Main | Usage |
The documentation for this module is missing. Click here to create it.
-- Adapted from: https://bucket.miraheze.org/wiki/Module:BucketGet
local p = {}
local getArgs = require('Module:Arguments').getArgs
local Bucket = mw.bucket
local NULL = {}
local TYPE_ORDER = {
["nil"] = 1,
number = 2,
string = 3,
boolean = 4,
table = 5
}
--------------------------------------------------------------------------------
-- Comparison / sorting
--------------------------------------------------------------------------------
local function compareValues(a, b)
if a == b then
return 0
end
local function typeOrder(val)
local t = type(val)
return TYPE_ORDER[t] or 6
end
local a_type = typeOrder(a)
local b_type = typeOrder(b)
if a_type ~= b_type then
return a_type < b_type and -1 or 1
end
if a_type == 1 then
return 0
elseif a_type == 2 then
if a < b then
return -1
else
return 1
end
elseif a_type == 3 then
if a < b then
return -1
else
return 1
end
elseif a_type == 4 then
if a == false and b == true then
return -1
else
return 1
end
elseif a_type == 5 then
local max_len = math.max(#a, #b)
for i = 1, max_len do
local a_elem = a[i]
local b_elem = b[i]
local cmp = compareValues(a_elem, b_elem)
if cmp ~= 0 then
return cmp
end
end
if #a < #b then
return -1
elseif #a > #b then
return 1
else
return 0
end
end
return 0
end
local function sortResults(results, order_by)
table.sort(results, function(a, b)
for _, order in ipairs(order_by) do
local a_val = a[order.column]
local b_val = b[order.column]
local cmp = compareValues(a_val, b_val)
if cmp ~= 0 then
if order.direction == "ASC" then
return cmp == -1
else
return cmp == 1
end
end
end
return false
end)
end
--------------------------------------------------------------------------------
-- Table formatting
--------------------------------------------------------------------------------
local function formatTable(frame, results, columns, args)
local table_columns_str = args['table-columns']
if table_columns_str then
local by_alias = {}
for _, col in ipairs(columns) do
by_alias[col.alias] = col
end
local display_columns = {}
for part in table_columns_str:gmatch('[^,]+') do
local alias, new_label =
part:match('^%s*(.-)%s*=%s*(.-)%s*$')
if not alias then
alias = part:match('^%s*(.-)%s*$')
end
local col = by_alias[alias]
if col then
if new_label then
col.label = new_label
end
table.insert(display_columns, col)
end
end
columns = display_columns
end
local html = mw.html.create('table'):addClass('wikitable')
local header_row = html:tag('tr')
for _, col in ipairs(columns) do
header_row:tag('th'):wikitext(col.label)
end
for _, row in ipairs(results) do
local tr = html:tag('tr')
for _, col in ipairs(columns) do
local data = row[col.alias]
if data == nil then
data = '<i>null</i>'
else
data = tostring(data)
end
tr:tag('td'):wikitext(data)
end
end
if args['table-preprocess'] then
return frame:preprocess(tostring(html))
end
return tostring(html)
end
--------------------------------------------------------------------------------
-- Delimiters
--------------------------------------------------------------------------------
local function getDelimiter(args)
local delimiter = args['delimiter'] or ""
return delimiter:gsub("\\n", "\n")
end
--------------------------------------------------------------------------------
-- Template formatting
--------------------------------------------------------------------------------
local function formatTemplate(frame, results, columns, args)
local template_name = args['template']
if not template_name then
error("Template name must be specified for format=template")
end
local delimiter = getDelimiter(args)
local output = {}
for _, row in ipairs(results) do
local parts = {}
table.insert(parts, ("{{%s"):format(template_name))
for _, col in ipairs(columns) do
local value = row[col.alias]
if value == nil then
value = ''
elseif type(value) == "table" then
value = table.concat(value, '; ')
else
value = tostring(value)
end
table.insert(
parts,
("|%s=%s"):format(col.label, value)
)
end
table.insert(parts, "}}")
table.insert(output, table.concat(parts, ""))
end
return frame:preprocess(
table.concat(output, delimiter)
)
end
--------------------------------------------------------------------------------
-- Column parsing
--------------------------------------------------------------------------------
local function parseColumns(columns_str)
local columns = {}
for col_raw in columns_str:gmatch("[^,]+") do
local column
local alias
local col = col_raw:match("^%s*(.-)%s*$")
local eq_pos = col:find("=")
local as_pos, as_end =
col:lower():find("%sas%s")
if eq_pos then
column =
col:sub(1, eq_pos - 1):match("^%s*(.-)%s*$")
alias =
col:sub(eq_pos + 1):match("^%s*(.-)%s*$")
elseif as_pos then
column =
col:sub(1, as_pos - 1):match("^%s*(.-)%s*$")
alias =
col:sub(as_end + 1):match("^%s*(.-)%s*$")
else
column = col
alias = col
end
table.insert(columns, {
column = column,
alias = alias
})
end
return columns
end
--------------------------------------------------------------------------------
-- Column aliases
--------------------------------------------------------------------------------
local function renameColumns(results, columns)
for _, col in ipairs(columns) do
if col.alias ~= col.column then
for _, row in ipairs(results) do
row[col.alias] = row[col.column]
row[col.column] = nil
end
end
end
end
--------------------------------------------------------------------------------
-- Split columns
--------------------------------------------------------------------------------
local function applySplitColumns(results, columns, args)
local split_specs = {}
for _, col in ipairs(columns) do
local val = args[col.alias .. '-split']
if val ~= nil then
local count
if val == 'auto' then
count = 'auto'
elseif tonumber(val) and tonumber(val) >= 1 then
count = tonumber(val)
else
error(string.format(
"Invalid %s-split value '%s': must be 'auto' or a positive integer",
col.alias,
val
))
end
table.insert(split_specs, {
alias = col.alias,
count = count
})
end
end
if #split_specs == 0 then
return
end
for _, spec in ipairs(split_specs) do
-- Resolve "auto" counts.
if spec.count == 'auto' then
local max_n = 0
for _, row in ipairs(results) do
local v = row[spec.alias]
if type(v) == 'table' and #v > max_n then
max_n = #v
end
end
spec.count = max_n
end
-- Create the split columns.
for i = 1, spec.count do
local sub_alias = spec.alias .. i
table.insert(columns, {
alias = sub_alias,
label = args[sub_alias .. '-label'] or sub_alias
})
end
end
for _, row in ipairs(results) do
for _, spec in ipairs(split_specs) do
local v = row[spec.alias]
for i = 1, spec.count do
local val
if type(v) == 'table' then
val = v[i]
else
val = (i == 1) and v
end
row[spec.alias .. i] = val or ""
end
end
end
end
--------------------------------------------------------------------------------
-- Alias translation
--------------------------------------------------------------------------------
local function translateAliases(node, alias_to_column)
if node.type == 'and' or node.type == 'or' then
translateAliases(node.left, alias_to_column)
translateAliases(node.right, alias_to_column)
elseif node.type == 'not' then
translateAliases(node.operand, alias_to_column)
elseif node.type == 'comparison'
or node.type == 'filter_op' then
local original = alias_to_column[node.field]
if original then
node.field = original
end
end
end
--------------------------------------------------------------------------------
-- Order parsing
--------------------------------------------------------------------------------
local function parseOrderBy(order_by_str)
local order_by = {}
for col_raw in order_by_str:gmatch("[^,]+") do
local col = mw.text.trim(col_raw)
local column
local direction
local lower_col = col:lower()
local space_pos = lower_col:find("%s+")
if space_pos then
column =
mw.text.trim(
col:sub(1, space_pos - 1)
)
local dir =
mw.text.trim(
col:sub(space_pos + 1)
):lower()
if dir == "desc" then
direction = "DESC"
elseif dir == "asc" then
direction = "ASC"
else
error(
("Sort direction must be either ASC or DESC. %s provided.")
:format(dir)
)
end
else
column = col
direction = "ASC"
end
table.insert(order_by, {
column = column,
direction = direction
})
end
return order_by
end
--------------------------------------------------------------------------------
-- Unescape
--------------------------------------------------------------------------------
local function unescape(text)
if not text:match("UNIQ") then
return text
end
local unescaped = mw.text.unstripNoWiki(text)
local text, count =
string.gsub(unescaped, "<", "<")
text, count =
string.gsub(text, ">", ">")
text, count =
string.gsub(text, "<//nowiki>", "</nowiki>")
return text
end
--------------------------------------------------------------------------------
-- Userformat value normalization
--------------------------------------------------------------------------------
local function normalizeUserformatValue(value)
-- Missing values.
if value == nil then
return ''
end
-- Boolean false should not become "false".
if value == false then
return ''
end
-- Arrays.
if type(value) == "table" then
local values = {}
for _, v in ipairs(value) do
if v ~= nil and v ~= false then
local s = mw.text.trim(tostring(v))
if s ~= '' then
table.insert(values, s)
end
end
end
return table.concat(values, '; ')
end
-- Normal scalar value.
value = mw.text.trim(tostring(value))
-- Treat common null representations as empty.
local lower = mw.ustring.lower(value)
if lower == ''
or lower == 'null'
or lower == 'nil'
or lower == 'false' then
return ''
end
-- Remove non-breaking spaces if a bucket value happens
-- to contain them.
value = value:gsub('\194\160', ' ')
return mw.text.trim(value)
end
--------------------------------------------------------------------------------
-- Userformat
--------------------------------------------------------------------------------
local function formatUserformat(frame, results, columns, args)
local formatter = args['userformat']
if not formatter then
error("format=userformat expects an accompanying formatter")
end
formatter = unescape(formatter)
local delimiter = getDelimiter(args)
local output = {}
for _, row in ipairs(results) do
local formatted = formatter
for _, col in ipairs(columns) do
local value =
normalizeUserformatValue(row[col.alias])
local placeholder =
"%%" .. col.label .. "%%"
formatted =
formatted:gsub(
placeholder,
function()
return value
end
)
end
table.insert(output, formatted)
end
return frame:preprocess(
table.concat(output, delimiter)
)
end
--------------------------------------------------------------------------------
-- Grouping
--------------------------------------------------------------------------------
local function parseGroupBy(group_by_str)
local group_by = {}
for col_raw in group_by_str:gmatch("[^,]+") do
local col = mw.text.trim(col_raw)
table.insert(group_by, col)
end
return group_by
end
--------------------------------------------------------------------------------
-- Aggregation parsing
--------------------------------------------------------------------------------
local function parseAggregateArg(aggregate_str, columns)
local agg_specs = {}
if not aggregate_str
or aggregate_str:match('^%s*$') then
return agg_specs
end
for spec_str in aggregate_str:gmatch('[^,]+') do
spec_str =
spec_str:match('^%s*(.-)%s*$')
local output_col
local main_part
local as_pos, as_end =
spec_str:lower():find('%s+as%s+')
if as_pos then
main_part =
spec_str:sub(1, as_pos - 1)
:match('^%s*(.-)%s*$')
output_col =
spec_str:sub(as_end + 1)
:match('^%s*(.-)%s*$')
else
main_part = spec_str
output_col = nil
end
local func_name, args_str =
main_part:match(
'^(%w+)%s*%((.*)%)%s*$'
)
if func_name then
func_name = func_name:upper()
local args = {}
for arg in args_str:gmatch('%S+') do
table.insert(args, arg)
end
if not output_col then
output_col = args[1]
end
local is_new_col = true
for _, col_info in ipairs(columns) do
if col_info.alias == output_col then
is_new_col = false
break
end
end
table.insert(agg_specs, {
func = func_name,
args = args,
output_col = output_col,
is_new_col = is_new_col
})
else
-- Bare column name → TABLE
local col_name =
main_part:match('^%s*(.-)%s*$')
if col_name ~= '' then
if not output_col then
output_col = col_name
end
local is_new_col = true
for _, col_info in ipairs(columns) do
if col_info.alias == output_col then
is_new_col = false
break
end
end
table.insert(agg_specs, {
func = 'TABLE',
args = { col_name },
output_col = output_col,
is_new_col = is_new_col
})
end
end
end
return agg_specs
end
--------------------------------------------------------------------------------
-- Aggregation functions
--------------------------------------------------------------------------------
local function aggCount(rows, column)
return #rows
end
local function aggTable(rows, column)
local result = {}
for _, row in ipairs(rows) do
local value = row[column]
if value ~= nil then
if type(value) == "table" then
for _, v in ipairs(value) do
table.insert(result, v)
end
else
table.insert(result, value)
end
end
end
return result
end
local function aggMin(rows, column)
local min_val = nil
for _, row in ipairs(rows) do
local v = tonumber(row[column]) or row[column]
if min_val == nil or v < min_val then
min_val = v
end
end
return min_val
end
local function aggMax(rows, column)
local max_val = nil
for _, row in ipairs(rows) do
local v = tonumber(row[column]) or row[column]
if max_val == nil or v > max_val then
max_val = v
end
end
return max_val
end
local function aggAny(rows, column)
return rows[1][column]
end
--------------------------------------------------------------------------------
-- Value formats
--------------------------------------------------------------------------------
local function applyValueFormats(results, columns, args, frame)
local formats_to_apply = {}
local link_columns = {
page_name = true,
page_name_sub = true
}
local table_name = args[1]
local data =
mw.loadJsonData("Bucket:" .. table_name)
for column, info in pairs(data) do
if info['type'] == 'PAGE' then
link_columns[column] = true
end
end
for _, col in ipairs(columns) do
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"
)
local delimiter =
args[col.alias .. "-delimiter"]
or "; "
delimiter =
delimiter:gsub("\\n", "\n")
table.insert(formats_to_apply, {
column = col.alias,
format = unescape(formatter),
delimiter = delimiter
})
end
end
if #formats_to_apply == 0 then
return results
end
for _, row in ipairs(results) do
for _, fmt_info in ipairs(formats_to_apply) do
local value = row[fmt_info.column]
if value == nil then
value = ''
elseif type(value) == "table" then
local formatted_values = {}
for _, v in ipairs(value) do
local formatted =
fmt_info.format:gsub(
"%%s",
function()
return v or ''
end
)
table.insert(
formatted_values,
formatted
)
end
row[fmt_info.column] =
table.concat(
formatted_values,
fmt_info.delimiter
)
else
local formatted =
fmt_info.format:gsub(
"%%s",
function()
return value
end
)
row[fmt_info.column] = formatted
end
end
end
return results
end
--------------------------------------------------------------------------------
-- Group building
--------------------------------------------------------------------------------
local function buildGroups(results, group_by_cols)
local groups = {}
local group_order = {}
for _, row in ipairs(results) do
local key_parts = {}
for _, col in ipairs(group_by_cols) do
local value = row[col]
if type(value) == 'table' then
table.insert(
key_parts,
table.concat(value, '\0')
)
elseif value == nil then
table.insert(key_parts, '\0')
else
table.insert(
key_parts,
tostring(value)
)
end
end
local key =
table.concat(key_parts, '\1')
if not groups[key] then
groups[key] = {}
table.insert(group_order, key)
end
table.insert(groups[key], row)
end
return groups, group_order
end
--------------------------------------------------------------------------------
-- Aggregation
--------------------------------------------------------------------------------
local function applyAggregation(group_rows, agg_specs, new_row)
local format_specs = {}
for _, spec in ipairs(agg_specs) do
if spec.func == 'FORMAT' then
table.insert(format_specs, spec)
elseif spec.func == 'COUNT' then
new_row[spec.output_col] =
aggCount(
group_rows,
spec.args[1]
)
elseif spec.func == 'TABLE' then
new_row[spec.output_col] =
aggTable(
group_rows,
spec.args[1]
)
elseif spec.func == 'MIN' then
new_row[spec.output_col] =
aggMin(
group_rows,
spec.args[1]
)
elseif spec.func == 'MAX' then
new_row[spec.output_col] =
aggMax(
group_rows,
spec.args[1]
)
elseif spec.func == 'ANY' then
new_row[spec.output_col] =
aggAny(
group_rows,
spec.args[1]
)
end
end
return format_specs
end
local function applyFormatSpec(new_row, spec, fmt_template)
local array_len = nil
for _, arg_col in ipairs(spec.args) do
if type(new_row[arg_col]) == 'table' then
array_len = #new_row[arg_col]
break
end
end
if array_len then
local results = {}
for i = 1, array_len do
local result_str = fmt_template
for _, arg_col in ipairs(spec.args) do
local val = new_row[arg_col]
local sv
if type(val) == 'table' then
sv =
tostring(
val[i] ~= nil
and val[i]
or ''
)
elseif val == nil then
sv = ''
else
sv = tostring(val)
end
result_str =
result_str:gsub(
'%%' .. arg_col .. '%%',
function()
return sv
end
)
end
table.insert(results, result_str)
end
new_row[spec.output_col] = results
else
local result_str = fmt_template
for _, arg_col in ipairs(spec.args) do
local val = new_row[arg_col]
result_str =
result_str:gsub(
'%%' .. arg_col .. '%%',
function()
return val == nil
and ''
or tostring(val)
end
)
end
new_row[spec.output_col] = result_str
end
end
local function groupResults(
results,
group_by_cols,
agg_specs,
columns,
group_sort,
args
)
if #group_by_cols == 0 then
return results
end
local group_by_set = {}
for _, col in ipairs(group_by_cols) do
group_by_set[col] = true
end
local covered = {}
for _, spec in ipairs(agg_specs) do
covered[spec.output_col] = true
end
local groups, group_order =
buildGroups(
results,
group_by_cols
)
local grouped_results = {}
for _, key in ipairs(group_order) do
local group_rows = groups[key]
if group_sort then
sortResults(
group_rows,
group_sort
)
end
local new_row = {}
for _, col in ipairs(group_by_cols) do
new_row[col] =
group_rows[1][col]
end
local format_specs =
applyAggregation(
group_rows,
agg_specs,
new_row
)
for _, col_info in ipairs(columns) do
local col_name = col_info.alias
if not group_by_set[col_name]
and not covered[col_name] then
new_row[col_name] =
aggAny(
group_rows,
col_name
)
end
end
for _, spec in ipairs(format_specs) do
local fmt_template =
args[spec.output_col .. '-agg']
if fmt_template then
applyFormatSpec(
new_row,
spec,
unescape(fmt_template)
)
end
end
table.insert(
grouped_results,
new_row
)
end
return grouped_results
end
--------------------------------------------------------------------------------
-- Filter parser
--------------------------------------------------------------------------------
local T = {
PARENTHESIS_L = 1,
PARENTHESIS_R = 2,
AND = 3,
OR = 4,
NOT = 5,
NULL = 6,
COMP = 7,
STR = 8,
NUM = 9,
BOOL = 10,
ID = 11,
EOF = 12
}
local KW = {
['AND'] = T.AND,
['OR'] = T.OR,
['NOT'] = T.NOT,
['true'] = T.BOOL,
['false'] = T.BOOL,
['NULL'] = T.NULL
}
local FILTER_OPS = {
CONTAINS = true,
ICONTAINS = true,
MATCH = true
}
--------------------------------------------------------------------------------
-- Joins
--------------------------------------------------------------------------------
local function parseJoins(join_str)
local joins = {}
for join_part in join_str:gmatch("[^,]+") do
local join = {}
local parts = {}
for part in join_part:gmatch("[^=]+") do
table.insert(
parts,
mw.text.trim(part)
)
end
if #parts == 2 then
join.left = parts[1]
join.right = parts[2]
table.insert(
joins,
join
)
end
end
return joins
end
--------------------------------------------------------------------------------
-- Tokenizer
--------------------------------------------------------------------------------
local function tokenize(s)
local t, i = {}, 1
while i <= #s do
local c = s:sub(i, i)
if c:match('%s') then
i = i + 1
elseif c == '(' then
table.insert(
t,
{ type = T.PARENTHESIS_L }
)
i = i + 1
elseif c == ')' then
table.insert(
t,
{ type = T.PARENTHESIS_R }
)
i = i + 1
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("[%w_]") then
local w =
s:match(
'^[%w_.]+',
i
)
table.insert(
t,
{
type =
KW[w:upper()]
or KW[w]
or T.ID,
val = w
}
)
i = i + #w
elseif c:match('["\']') then
local q, str = c, ''
for j = i + 1, #s do
local ch = s:sub(j, j)
if ch == q then
table.insert(
t,
{
type = T.STR,
val = str
}
)
i = j + 1
break
end
str = str .. ch
end
elseif c:match('[=<>!]') then
local op =
s:match(
'[=<>!]+',
i
)
if not op then
error(
"Invalid operator at position "
.. i
)
end
table.insert(
t,
{
type = T.COMP,
val = op
}
)
i = i + #op
else
error(
"Unexpected character '"
.. c
.. "' at position "
.. i
)
end
end
return t
end
--------------------------------------------------------------------------------
-- Expression parser
--------------------------------------------------------------------------------
local parsePrimary
local function parseNotExpr(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
else
return parsePrimary(
toks,
pos
)
end
end
local function parseAndExpr(toks, 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
}
new_pos = next_pos
end
return left, new_pos
end
local function parseExpression(toks, 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
}
new_pos = next_pos
end
return left, new_pos
end
parsePrimary = function(toks, pos)
if toks[pos].type == T.PARENTHESIS_L then
local node, new_pos =
parseExpression(
toks,
pos + 1
)
if toks[new_pos].type
~= T.PARENTHESIS_R then
error(
"Expected ')' at position "
.. new_pos
)
end
return node, new_pos + 1
elseif toks[pos].type == T.ID then
local op_tok = toks[pos + 1]
if op_tok
and op_tok.type == T.ID
and FILTER_OPS[
op_tok.val:upper()
] then
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
error(
"Expected value after filter operator at position "
.. (pos + 2)
)
end
local value =
(v.type == T.NUM)
and v.val
or tostring(v.val)
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
local v = toks[pos + 2]
if v.type ~= T.STR
and v.type ~= T.NUM
and v.type ~= T.BOOL
and v.type ~= T.NULL then
error(
"Expected value after comparison operator at position "
.. (pos + 2)
)
end
local value
if v.type == T.NULL then
value = NULL
elseif v.type == T.BOOL then
value = (v.val == 'true')
else
value = v.val
end
return {
type = 'comparison',
field = toks[pos].val,
op = op_tok.val,
value = value
}, pos + 3
else
error(
"Expected comparison or filter operator after identifier at position "
.. (pos + 1)
)
end
else
error(
"Unexpected token at position "
.. pos
.. ", expected identifier or '('"
)
end
end
local function parseCond(toks, pos)
local node, new_pos =
parseExpression(
toks,
pos
)
return node, new_pos
end
local function toBucket(n)
if n.type == 'and' then
return Bucket.And(
toBucket(n.left),
toBucket(n.right)
)
elseif n.type == 'or' then
return Bucket.Or(
toBucket(n.left),
toBucket(n.right)
)
elseif n.type == 'not' then
return Bucket.Not(
toBucket(n.operand)
)
elseif n.type == 'comparison' then
local val =
(n.value == NULL)
and Bucket.Null()
or n.value
return {
n.field,
n.op,
val
}
end
end
--------------------------------------------------------------------------------
-- Filter evaluation
--------------------------------------------------------------------------------
local FILTER_FUNCS = {
CONTAINS = function(v, s)
return type(v) == 'string'
and mw.ustring.find(
v,
s,
1,
true
) ~= nil
end,
ICONTAINS = function(v, s)
return type(v) == 'string'
and mw.ustring.find(
mw.ustring.lower(v),
mw.ustring.lower(s),
1,
true
) ~= nil
end,
MATCH = function(v, s)
return type(v) == 'string'
and mw.ustring.match(v, s)
~= nil
end
}
local COMP_FUNCS = {
['='] = function(a, b)
if b == NULL then
return a == nil
end
return a == b
end,
['!='] = function(a, b)
if b == NULL then
return a ~= nil
end
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
}
local function evalFilter(node, row)
if node.type == 'and' then
return evalFilter(
node.left,
row
) and evalFilter(
node.right,
row
)
elseif node.type == 'or' then
return evalFilter(
node.left,
row
) or evalFilter(
node.right,
row
)
elseif node.type == 'not' then
return not evalFilter(
node.operand,
row
)
elseif node.type == 'filter_op' then
local fn =
FILTER_FUNCS[node.op]
if not fn then
error(
'Unknown filter op: '
.. node.op
)
end
return fn(
row[node.field],
node.value
)
elseif node.type == 'comparison' then
local fn =
COMP_FUNCS[node.op]
if not fn then
error(
'Unknown comparison op: '
.. node.op
)
end
return fn(
row[node.field],
node.value
)
end
end
local function applyFilter(results, ast)
local filtered = {}
for _, row in ipairs(results) do
if evalFilter(ast, row) then
table.insert(
filtered,
row
)
end
end
return filtered
end
--------------------------------------------------------------------------------
-- Query
--------------------------------------------------------------------------------
local function runQuery(columns, args)
local bucket_name =
mw.text.trim(
args[1]
or args['bucket']
or ''
)
if bucket_name == '' then
error(
'Must specify the name of the bucket'
)
end
for _, col in ipairs(columns) do
col.label =
args[col.alias .. '-label']
or col.alias
end
local all_originals = {}
for _, col in ipairs(columns) do
all_originals[col.column] = true
end
local alias_to_column = {}
for _, col in ipairs(columns) do
if col.alias ~= col.column
and not all_originals[col.alias] then
alias_to_column[col.alias] =
col.column
end
end
local column_names = {}
for _, col in ipairs(columns) do
table.insert(
column_names,
col.column
)
end
local query =
mw.bucket(bucket_name)
.select(unpack(column_names))
local join_str =
args['join_on']
or args['join']
if join_str then
local joins =
parseJoins(join_str)
for _, join in ipairs(joins) do
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
)
end
end
local where_str = args['where']
if where_str then
local toks =
tokenize(where_str)
local ast =
parseCond(
toks,
1
)
translateAliases(
ast,
alias_to_column
)
query =
query.where(
toBucket(ast)
)
end
local limit =
tonumber(args['limit'])
if limit ~= nil then
query = query.limit(limit)
end
local offset =
tonumber(args['offset'])
if offset ~= nil then
query = query.offset(offset)
end
return query:run()
end
--------------------------------------------------------------------------------
-- Main
--------------------------------------------------------------------------------
function p.get(frame)
local args =
getArgs(frame)
local columns_str =
args['select']
or args['fields']
if not columns_str then
error(
"Must specify which columns to select."
)
end
local columns =
parseColumns(columns_str)
local results =
runQuery(
columns,
args
)
renameColumns(
results,
columns
)
local filter_str =
args['filter']
if filter_str then
local toks =
tokenize(filter_str)
local ast =
parseCond(
toks,
1
)
results =
applyFilter(
results,
ast
)
end
if #results == 0 then
return args['default']
or "No result"
end
local group_by_str =
args['group_by']
if group_by_str then
local group_by_cols =
parseGroupBy(
group_by_str
)
local agg_specs =
parseAggregateArg(
args['aggregate']
or '',
columns
)
for _, spec in ipairs(agg_specs) do
if spec.is_new_col then
table.insert(
columns,
{
column = spec.output_col,
alias = spec.output_col,
label =
args[
spec.output_col
.. '-label'
]
or spec.output_col
}
)
end
end
local group_sort =
parseOrderBy(
args['group-sort']
or ''
)
results =
groupResults(
results,
group_by_cols,
agg_specs,
columns,
group_sort,
args
)
end
applySplitColumns(
results,
columns,
args
)
local order_by_str =
args['order_by']
or args['order']
if order_by_str then
local order_by_rules =
parseOrderBy(
order_by_str
)
sortResults(
results,
order_by_rules
)
end
applyValueFormats(
results,
columns,
args,
frame
)
local format =
args['format']
or "table"
local prefix = ""
local resultsHeader =
args['results_header']
or "'''%TOTALRESULTS%''' result%S%:"
local header =
args['header']
or ""
local footer =
args['footer']
or ""
local text = ""
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_results_header']
local showResultsHeader = false
if
(
args['results_header']
~= nil
and args['results_header']
~= ""
)
or yesResultsHeader == true
or (
type(yesResultsHeader)
== "string"
and (
yesResultsHeader:lower()
== "true"
or yesResultsHeader
== "1"
)
)
then
showResultsHeader = true
end
if showResultsHeader then
prefix =
resultsHeader
.. "\n"
end
if format == "table" then
text =
formatTable(
frame,
results,
columns,
args
)
elseif format == "template" then
text =
formatTemplate(
frame,
results,
columns,
args
)
elseif format == "userformat" then
text =
formatUserformat(
frame,
results,
columns,
args
)
else
error(
string.format(
"Invalid format %s specified.",
format
)
)
end
return
prefix
.. header
.. text
.. footer
end
--------------------------------------------------------------------------------
-- Exports for testing
--------------------------------------------------------------------------------
p.sortResults = sortResults
p.aggCount = aggCount
p.aggTable = aggTable
p.groupResults = groupResults
p.parseAggregateArg = parseAggregateArg
p.T = T
p.NULL = NULL
p.tokenize = tokenize
p.parseCond = parseCond
p.applyFilter = applyFilter
p.renameColumns = renameColumns
p.applySplitColumns = applySplitColumns
p.translateAliases = translateAliases
return p