Module:Cargo query utilities
Appearance
Documentation for this module may be created at Module:Cargo query utilities/doc
--[[
Module:Cargo query
Part of the Module:Utilities family.
Everything that talks to Cargo directly: building WHERE clauses, fetching
rows, finding ranks, extracting genealogies, and reading table schemas.
All functions are re-exported through Module:Utilities for backward compatibility.
Functions:
get_cargo_row Query a Cargo table and return the first matching row
get_focal_rank Find which field in a row matches a given value
get_genealogy Build an ordered ancestry list from a Cargo row
get_results_map Index a Cargo results array by a focal field
extract_top_cargo_row Pull field values from the first row of a Cargo result
fetch_cargo_column Return all values of one field from a Cargo table
get_cargo_table_schema Parse a Cargo template to extract its field schema
build_where_clause Build a WHERE clause for a Cargo query
parse_from_schema Parse template args using a JSON schema page
--]]
local cargo = mw.ext.cargo
local cargo_query = {}
-- ============================================================
-- Row fetching
-- ============================================================
--[[
Queries a Cargo table for the first row where any field in all_ranks_list
matches search_string. Builds a single compound OR query for efficiency.
Returns a one-element table (same structure as cargo.query output),
or an empty table if no match is found.
--]]
function cargo_query.get_cargo_row(cargo_table_name, search_string, all_ranks_list)
if type(cargo_table_name) ~= "string"
or type(search_string) ~= "string"
or type(all_ranks_list) ~= "table" then
error("Invalid arguments: expected (string, string, table)")
end
local where_clauses = {}
for _, field in ipairs(all_ranks_list) do
table.insert(where_clauses, field .. " = '" .. search_string .. "'")
end
local results = cargo.query(
cargo_table_name,
table.concat(all_ranks_list, ", "),
{ where = table.concat(where_clauses, " OR "), limit = 1 }
)
return results and { results[1] } or {}
end
--[[
Searches a pre-fetched Cargo table (array of rows) for the first field
in all_ranks_list whose value matches the given value.
Returns the field name string, or nil if not found.
--]]
function cargo_query.get_focal_rank(cargo_table, value, all_ranks_list)
for _, row in ipairs(cargo_table) do
for _, field in ipairs(all_ranks_list) do
if row[field] == value then
return field
end
end
end
return nil
end
-- ============================================================
-- Genealogy
-- ============================================================
--[[
Builds an ordered ancestry list (genealogy) for a focal taxon from a
pre-fetched Cargo table.
Returns a flat array starting with the focal taxon value, then each
ancestor in ascending rank order.
Returns an empty table if no matching row is found.
--]]
function cargo_query.get_genealogy(cargo_table, focal_rank, search_string, all_rank_list)
if not cargo_table or not focal_rank or not search_string or not all_rank_list then
error("All arguments are required: cargo_table, focal_rank, search_string, all_rank_list")
end
local focal_row = nil
for _, row in ipairs(cargo_table) do
if row[focal_rank] == search_string then
focal_row = row
break
end
end
if not focal_row then return {} end
local genealogy = {}
table.insert(genealogy, focal_row[focal_rank])
local focal_rank_index = nil
for i, rank in ipairs(all_rank_list) do
if rank == focal_rank then
focal_rank_index = i
break
end
end
if not focal_rank_index then
error("Focal rank not found in all_rank_list")
end
for i = focal_rank_index - 1, 1, -1 do
local rank = all_rank_list[i]
if focal_row[rank] then
table.insert(genealogy, focal_row[rank])
end
end
return genealogy
end
-- ============================================================
-- Results indexing and extraction
-- ============================================================
--[[
Transforms an array of Cargo rows into a map keyed by the value of
cargo_focal_field, allowing O(1) lookup by that field's value.
--]]
function cargo_query.get_results_map(cargo_results, cargo_focal_field)
if type(cargo_results) ~= "table" then
error("Expected cargo_results to be a table, got " .. type(cargo_results))
end
if type(cargo_focal_field) ~= "string" then
error("Expected cargo_focal_field to be a string, got " .. type(cargo_focal_field))
end
local results_map = {}
for _, entry in ipairs(cargo_results) do
if type(entry) ~= "table" then
error("Expected each entry in cargo_results to be a table, got " .. type(entry))
end
if entry[cargo_focal_field] == nil then
error("Expected field '" .. cargo_focal_field .. "' to exist in each entry")
end
results_map[entry[cargo_focal_field]] = entry
end
return results_map
end
--[[
Extracts field values from the first row of a Cargo results array into a
flat table. If fields is nil, all fields from the first row are used.
--]]
function cargo_query.extract_top_cargo_row(cargo_results, fields)
local cargo_args = {}
if not fields and cargo_results[1] then
fields = ""
for key in pairs(cargo_results[1]) do
fields = fields .. key .. ", "
end
fields = fields:sub(1, -3)
end
if cargo_results[1] then
for field in fields:gmatch("[^,]+") do
local trimmed = field:match("^%s*(.-)%s*$")
cargo_args[trimmed] = cargo_results[1][trimmed]
end
end
return cargo_args
end
--[[
Returns all values of a single field from a Cargo table as a flat array.
--]]
function cargo_query.fetch_cargo_column(table_name, field_name)
local cargo_results = mw.ext.cargo.query(table_name, field_name, { limit = 5000 })
local values = {}
for _, row in ipairs(cargo_results) do
table.insert(values, row[field_name])
end
return values
end
-- ============================================================
-- Schema parsing
-- ============================================================
--[[
Reads a Cargo template and parses its #cargo_declare block into a field
schema table of the form:
{ FieldName = { type = "list_of_page", delimiter = ";" }, ... }
Returns schema, debug_messages.
--]]
function cargo_query.get_cargo_table_schema(cargo_template_name)
local debug_messages = "Debug: Entered get_cargo_table_schema function.\n"
if not cargo_template_name or cargo_template_name == "" then
debug_messages = debug_messages .. "Error: Template name is required.\n"
return nil, debug_messages
end
debug_messages = debug_messages .. "Template name: " .. cargo_template_name .. "\n"
local template_title = mw.title.new(cargo_template_name, "Template")
if not template_title then
debug_messages = debug_messages .. "Error: Failed to create title object for template.\n"
return nil, debug_messages
end
local template_content = template_title:getContent()
if not template_content then
debug_messages = debug_messages .. "Error: Failed to retrieve template content.\n"
return nil, debug_messages
end
local schema_string = template_content:match("{{#cargo_declare.-(.-)}}")
if not schema_string then
debug_messages = debug_messages .. "Error: Failed to find cargo_declare in template content.\n"
return nil, debug_messages
end
debug_messages = debug_messages .. "Cargo declare block found.\n"
local schema = {}
for field_name, field_type_raw in schema_string:gmatch("|([^|=]+)%s*=%s*([^\n|]+)") do
field_name = mw.text.trim(field_name)
local field_info = {}
local list_delimiter, base_type = field_type_raw:match("List%s*%(%s*(.-)%s*%)%s*of%s*(%w+)")
if list_delimiter and base_type then
field_info.type = "list_of_" .. string.lower(base_type)
field_info.delimiter = list_delimiter
elseif field_type_raw:match("List") then
local base_type_fallback = field_type_raw:match("List of%s*(%w+)")
if base_type_fallback then
field_info.type = "list_of_" .. string.lower(base_type_fallback)
else
field_info.type = "list_of_unknown"
end
field_info.delimiter = ","
debug_messages = debug_messages ..
"Warning: No delimiter found for field '" .. field_name .. "'. Defaulting to comma.\n"
else
field_info.type = string.lower(mw.text.trim(field_type_raw))
field_info.delimiter = nil
end
schema[field_name] = field_info
end
return schema, debug_messages
end
--[[
Parses template args into a structured table using a JSON schema stored
at "JSON:<theme_name>_schema". Field names are converted from Cargo
Sentence_Case to snake_case keys.
--]]
function cargo_query.parse_from_schema(args, theme_name)
local schema_page = "JSON:" .. theme_name .. "_schema"
local success, table_schema = pcall(mw.loadJsonData, schema_page)
if not success or not table_schema then
mw.log("Warning: Could not load schema from " .. schema_page)
return args
end
-- Requires Module:String utilities for parse_arguments;
-- loaded lazily to avoid circular dependency risk.
local str = require("Module:String utilities")
local parsed = {}
for field_name, field_info in pairs(table_schema) do
local cargo_field_name = field_name:gsub("^%l", string.upper):gsub("_%l", function(s)
return "_" .. s:sub(2):upper()
end)
local value = args[cargo_field_name]
if value and value ~= "" then
local parse_type = field_info.type:gsub("list_of_", "")
parsed[field_name:lower()] = str.parse_arguments(value, parse_type)
end
end
return parsed
end
-- ============================================================
-- WHERE clause construction
-- ============================================================
--[[
Builds a Cargo WHERE clause from a list of filter values and optional
exclusion values. Handles both scalar (=) and list (HOLDS) field types.
--]]
function cargo_query.build_where_clause(cargo_focal_field_type, cargo_focal_field, filter_values_list, not_values_list)
not_values_list = not_values_list or {}
if cargo_focal_field == nil then
error("cargo_focal_field cannot be nil")
end
if filter_values_list == nil then
error("filter_values_list cannot be nil")
end
-- Requires Module:String utilities for escaping helpers.
local str = require("Module:String utilities")
local is_list_type = cargo_focal_field_type == "list_of_string"
or cargo_focal_field_type == "list_of_page"
or cargo_focal_field_type == "list_of_url"
or cargo_focal_field_type == "list_of_file"
local where_clauses = {}
for _, value in ipairs(filter_values_list) do
if value == nil then error("Value in filter_values_list cannot be nil") end
if is_list_type then
local v = str.escape_string_for_holds(value)
table.insert(where_clauses, string.format("%s HOLDS '%s'", cargo_focal_field, v))
else
local v = str.escape_string_for_sql(value)
table.insert(where_clauses, string.format("%s = '%s'", cargo_focal_field, v))
end
end
local not_clauses = {}
for _, value in ipairs(not_values_list) do
if value == nil then error("Value in not_values_list cannot be nil") end
if is_list_type then
local v = str.escape_string_for_holds(value)
table.insert(not_clauses, string.format("%s HOLDS '%s'", cargo_focal_field, v))
else
local v = str.escape_string_for_sql(value)
table.insert(not_clauses, string.format("%s != '%s'", cargo_focal_field, v))
end
end
local where_clause = table.concat(where_clauses, " OR ")
if #not_clauses > 0 then
where_clause = string.format(
"(%s) AND NOT (%s)",
where_clause,
table.concat(not_clauses, " AND NOT ")
)
end
return where_clause
end
return cargo_query