Module:BucketGet: Difference between revisions
Added upstream update and bug fixes for BucketGet |
ReisuDesign (talk | contribs) No edit summary |
||
| (2 intermediate revisions by 2 users not shown) | |||
| 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 | |||
-- Make missing split values genuinely empty. | |||
if val == nil or val == false then | |||
val = "" | |||
elseif type(val) == "string" then | |||
val = mw.text.trim(val) | |||
end | |||
row[spec.alias .. i] = val | |||
end | end | ||
end | end | ||
end | end | ||
end | end | ||
-------------------------------------------------------------------------------- | |||
-- Alias translation | |||
-------------------------------------------------------------------------------- | |||
local function translateAliases(node, alias_to_column) | local function translateAliases(node, alias_to_column) | ||
| Line 260: | Line 390: | ||
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 404: | ||
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 450: | ||
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 467: | ||
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 or 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)) | |||
-- Ignore empty/null array entries. | |||
local lower = mw.ustring.lower(s) | |||
if s ~= '' | |||
and lower ~= 'null' | |||
and lower ~= 'nil' | |||
and lower ~= 'false' | |||
and lower ~= '<i>null</i>' then | |||
table.insert(values, s) | |||
end | |||
end | |||
end | |||
return table.concat(values, '; ') | |||
end | |||
-- Normal scalar value. | |||
value = mw.text.trim(tostring(value)) | |||
-- Treat all common empty/null representations as empty. | |||
local lower = mw.ustring.lower(value) | |||
if lower == '' | |||
or lower == 'null' | |||
or lower == 'nil' | |||
or lower == 'false' | |||
or lower == '<i>null</i>' then | |||
return '' | |||
end | |||
-- Remove non-breaking spaces. | |||
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 .. "%%" | |||
local placeholder = "%%" .. col.label .. "%%" | formatted = | ||
formatted = formatted:gsub(placeholder, function() return value end) | formatted:gsub( | ||
placeholder, | |||
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 635: | ||
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 654: | ||
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 668: | ||
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 688: | ||
end | end | ||
end | end | ||
table.insert(agg_specs, { | table.insert(agg_specs, { | ||
func = 'TABLE', | func = 'TABLE', | ||
| Line 417: | Line 698: | ||
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 712: | ||
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 726: | ||
end | end | ||
end | end | ||
return result | return result | ||
end | end | ||
| Line 443: | Line 732: | ||
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 746: | ||
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 761: | ||
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 785: | ||
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 821: | ||
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 | |||
-- Empty values stay empty. | |||
if value == nil | |||
or value == false | |||
or ( | |||
type(value) == "string" | |||
and mw.text.trim(value) == "" | |||
) | |||
or ( | |||
type(value) == "string" | |||
and mw.ustring.lower( | |||
mw.text.trim(value) | |||
) == "<i>null</i>" | |||
) then | |||
row[fmt_info.column] = '' | |||
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 | local clean = v | ||
if clean == nil or clean == false then | |||
clean = "" | |||
elseif type(clean) == "string" then | |||
clean = mw.text.trim(clean) | |||
end | |||
local lower = | |||
mw.ustring.lower( | |||
tostring(clean) | |||
) | |||
if clean ~= "" | |||
and lower ~= "null" | |||
and lower ~= "nil" | |||
and lower ~= "false" | |||
and lower ~= "<i>null</i>" then | |||
local formatted = | |||
fmt_info.format:gsub( | |||
"%%s", | |||
function() | |||
return clean | |||
end | |||
) | |||
table.insert( | |||
formatted_values, | |||
formatted | |||
) | |||
end | |||
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 898: | ||
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 998: | ||
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 1,008: | ||
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,079: | ||
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,085: | ||
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,183: | ||
['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,436: | ||
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,553: | ||
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,710: | ||
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[' | 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,846: | ||
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[' | 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,925: | ||
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[' | |||
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[' | ) | ||
: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[' | |||
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 | ||