Jump to content

Module:Cargo query utilities

From HopperWiki

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
Cookies help us deliver our services. By using our services, you agree to our use of cookies.