Jump to content

Module:BucketGet: Difference between revisions

From The Petit Planet Wiki
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 (a == false and b == true) then return -1 else return 1 end
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


-- In case an earlier step introduced a template that needs to be preprocessed.
if args['table-preprocess'] then
if args['table-preprocess'] then
return frame:preprocess(tostring(html));
return frame:preprocess(tostring(html))
end
end


return tostring(html)
return tostring(html)
end
end
--------------------------------------------------------------------------------
-- 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, alias
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


-- Convert column names to their alias
--------------------------------------------------------------------------------
-- 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: max table length across all rows
 
-- 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 new columns based on count
 
-- 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, direction
 
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, "&lt;", "<")
 
text, count = string.gsub(text, "&gt;", ">")
local text, count =
text, count = string.gsub(text, "<//nowiki>", "</nowiki>")
string.gsub(unescaped, "&lt;", "<")
 
text, count =
string.gsub(text, "&gt;", ">")
 
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 = {}


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 =
if value == nil then
normalizeUserformatValue(row[col.alias])
value = ''
 
elseif type(value) == "table" then
local placeholder =
value = table.concat(value, '; ')
"%%" .. col.label .. "%%"
else
 
value = tostring(value)
formatted =
end
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+')


-- Check for AS alias
local output_col, main_part
local as_pos, as_end = spec_str:lower():find('%s+as%s+')
if as_pos then
if as_pos then
main_part = 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


-- Check for FUNC(args) pattern
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 .. '%%',
 
function() return val == nil and '' or tostring(val) end)
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 }); i = i + 1
table.insert(
t,
{ type = T.PARENTHESIS_L }
)
 
i = i + 1
 
elseif c == ')' then
elseif c == ')' then
table.insert(t, { type = T.PARENTHESIS_R }); i = i + 1
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 }); i = j + 1; break
table.insert(
t,
{
type = T.STR,
val = str
}
)
 
i = j + 1
break
end
end
str = str .. ch
str = str .. ch
end
end
elseif c:match('%d') or c == '.' then
 
local n = s:match('^%-?%d*%.?%d+', i)
table.insert(t, { type = T.NUM, val = tonumber(n) })
i = i + #n
elseif c:match('[=<>!]') then
elseif c:match('[=<>!]') then
local op = 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


-- Break out of the dependency loop
--------------------------------------------------------------------------------
-- 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 = function(v, s) return type(v) == 'string' and mw.ustring.find(v, s, 1, true) ~= nil end,
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'
type(v) == 'string' and
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     = function(v, s) return type(v) == 'string' and mw.ustring.match(v, s) ~= nil end,
 
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) if b == NULL then return a == nil end; return a == b end,
['='] = function(a, b)
['!='] = function(a, b) if b == NULL then return a ~= nil end; return a ~= b end,
if b == NULL then
['<'] = function(a, b) return compareValues(a, b) == -1 end,
return a == nil
['>'] = function(a, b) return compareValues(a, b) == 1 end,
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 query = mw.bucket(bucket_name).select(unpack(column_names))


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 results = runQuery(columns, args)
local filter_str =
-- mw.logObject(results)
args['filter']
renameColumns(results, columns)


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']


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


-- FIXME: this inserts markup that may need to be preprocessed, but for performance reasons
applyValueFormats(
-- we don't want to preprocess them row-by-row.
results,
applyValueFormats(results, columns, args, frame)
columns,
args,
frame
)
 
local format =
args['format']
or "table"


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 =
    resultsHeader = resultsHeader
#results
:gsub("%%TOTALRESULTS%%", resultsCount)
 
:gsub("%%ISARE%%", resultsCount == 1 and "is" or "are")
resultsHeader =
:gsub("%%S%%", resultsCount == 1 and "" or "s")
resultsHeader
:gsub("%%ES%%", resultsCount == 1 and "" or "es")
:gsub(
:gsub("%%HASHAVE%%", resultsCount == 1 and "has" or "have")
"%%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 (
showResultsHeader = true
type(yesResultsHeader)
== "string"
and (
yesResultsHeader:lower()
== "true"
or yesResultsHeader
== "1"
)
)
then
showResultsHeader = true
end
end


if showResultsHeader then
if showResultsHeader then
  prefix = resultsHeader .. "\n"
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

Module documentation

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, "&lt;", "<")

	text, count =
		string.gsub(text, "&gt;", ">")

	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