Jump to content

Module:Cargo query

From WhiskerWiki

Documentation for this module may be created at Module:Cargo query/doc

-- ================================================================================
-- Module Dependencies
-- ================================================================================
local cargo = mw.ext.cargo
local html = mw.html.create()
local getArgs = require('Module:Arguments').getArgs -- for processing arguments
local ibf = require("Module:Infobox_functions")
local utils = require("Module:Utilities")


-- ================================================================================
-- Main Table
-- ================================================================================
local p = {}





-- ================================================================================
-- Thin Cargo wrapper
-- ================================================================================

--[[This function is perhaps a better Cargo wrapper than `cargo_query` and should eventually replace it. It is much more versatile and a thinner wrapper than the latter function.

Description:
    A thin, general-purpose wrapper for the MediaWiki Cargo extension that
    allows complex, flexible SQL-like querying via a structured approach.
    It separates query logic (the WHERE clause) from dynamic user-supplied
    values to improve safety, maintainability, and clarity.

    This function is intended for advanced use cases where queries may contain
    multiple clauses, negations, or complex combinations of filters.
    For simpler one-field filters, see: `p.filter_cargo_table_enhanced`.

Usage:
    Template call example:
        {{#invoke:Cargo_query | thin_cargo_wrapper
        | cargo_table=Resource
        | cargo_fields=Name,Year_published
        | cargo_display_fields=Name,Year_published
        | cargo_where=Species_purview HOLDS __species__ AND Category HOLDS 'Media'
        | cargo_where_values=species={{PAGENAME}}
        | order_by=Year_published DESC
        | row_cutoff=5
        | character_cutoff=600
        }}

Parameters:
    - cargo_table:          Name of the Cargo table to query.
    - cargo_fields:         Comma-separated list of fields to query from Cargo.
    - cargo_display_fields: Fields to display in output (optional override).
    - cargo_where:          WHERE clause string with placeholder tokens like '__key__' or '__value__'.
    - cargo_where_values:   Comma-separated list of key=value pairs used to
                            replace placeholders in the WHERE clause.
                            Each value is SQL-escaped automatically before substitution.
                            If omitted, the placeholder __value__ will be replaced using
                            `filter_value`, or `PAGENAME` as a fallback.
    - filter_value:         A single fallback value (used with the __value__ token).
    - order_by:             Optional ORDER BY clause.
    - limit:                Optional result limit.
    - row_cutoff:           Row limit for collapsing tables (default: 3).
    - character_cutoff:     Max characters before collapsing content block.
    - options:              Optional JSON string for advanced display or link transformation.

Returns:
    - A formatted wiki table of results if any records are found.
    - A fallback message if no records match the query.
--]]
function p.thin_cargo_wrapper(frame)
    local args = getArgs(frame)
    local parent_args = frame:getParent().args

    local cargo_table = args.cargo_table
    local cargo_fields = args.cargo_fields
    local display_fields_raw = args.cargo_display_fields or args.cargo_fields
    local cargo_where = args.cargo_where
    local options = args.options and mw.text.jsonDecode(args.options) or {}
    local row_cutoff = (tonumber(args.row_cutoff or parent_args.row_cutoff) or 3) + 1
    local character_cutoff = tonumber(args.character_cutoff or parent_args.character_cutoff) or 600

	-- ========================================
	-- 1. Placeholder substitution
	-- ========================================
	if cargo_where then
	    local cargo_where_values_raw = args.cargo_where_values
	    if cargo_where_values_raw then
	        for pair in mw.text.gsplit(cargo_where_values_raw, ",") do
	            local key, value = pair:match("^(.-)=(.*)$")
	            if key and value and value ~= "" then
	                value = mw.text.trim(value):gsub("[#%%%-%-]", "")
	                local safe_value = "'" .. utils.escape_string_for_sql(value) .. "'"
	                cargo_where = cargo_where:gsub("__" .. key .. "__", safe_value)
	            end
	        end
	    end
	end

    -- ========================================
    -- 2. Schema and field parsing
    -- ========================================
    local cargo_table_schema = utils.get_cargo_table_schema(cargo_table)
    local display_fields = mw.text.split(display_fields_raw, ",")
    for i, v in ipairs(display_fields) do
        display_fields[i] = mw.text.trim(v)
    end

    local column_label_map = {}
    local column_labels_raw = args.column_labels or parent_args.column_labels
    if column_labels_raw then
        for pair in mw.text.gsplit(column_labels_raw, ",") do
            local k, v = pair:match("([^=]+)=([^=]+)")
            if k and v then
                column_label_map[mw.text.trim(k)] = mw.text.trim(v)
            end
        end
    else
        for _, field in ipairs(display_fields) do
            column_label_map[field] = string.gsub(field, "_", " ")
        end
    end

    -- ========================================
    -- 3. Run Cargo query
    -- ========================================
    local cargo_args = {
        where = cargo_where,
        orderBy = args.order_by,
        limit = tonumber(args.limit),
        groupBy = args.group_by
    }
    
    
    local cargo_results = cargo.query(cargo_table, cargo_fields, cargo_args)

    -- ========================================
    -- 4. Process and return
    -- ========================================
    local parsed = utils.parse_cargo_results(cargo_results, cargo_table_schema)
    parsed.results = utils.transform_link_fields(parsed.results, display_fields, cargo_table_schema, options)

    local collapse = (#parsed.results > row_cutoff)
    if #parsed.results > 0 then
        return utils.generate_wiki_table_enhanced(
            parsed.results,
            display_fields,
            collapse,
            character_cutoff,
            cargo_table_schema,
            column_label_map,
            true,
            nil,
            row_cutoff,
            options
        ) .. '<div style="clear: both;"></div>'
    else
        return "No results found in the database at this time—[[HopperWiki:About|please reach out]] if you have any to share!"
    end
end














-- ================================================================================
-- New enhanced argument handling and added features
-- ================================================================================
--[[
    filter_cargo_table_enhanced(frame)

    A structured, opinionated Cargo query wrapper designed for simple filtering
    of a single field by one or more values. The function is optimized for use
    in MediaWiki templates where a single page title or user-supplied value is
    used to filter a table, and optionally exclude some results.

    This wrapper is safer and more predictable than the raw `cargo_query` call,
    offering:
    - Auto-escaping of filter values
    - Support for both literal and list-style input
    - Auto-detection of field type to determine correct operators (e.g., `HOLDS`)
    - Optional support for fallback files in file fields
    - Output as a table, bulleted list, or count

    Parameters (via template or #invoke):
    - cargo_table: The name of the Cargo table to query
    - query_fields / cargo_fields: Fields to fetch from the Cargo table
    - table_columns / cargo_display_fields: Fields to display in the output
    - column_labels: Optional labels for output columns
    - cargo_focal_field: The field to filter against
    - filter_value / filter_values: The values to filter for
    - not_value / not_values: Values to exclude
    - *_literal: Whether to treat the input as a single literal (even if it has commas)
    - row_cutoff: Number of rows before collapsing the table
    - character_cutoff: Max character length before collapsing
    - default_file_fields: Map of fallback files for file fields
    - format: Output type: "table", "bulleted_list", or "count"
--]]
function p.filter_cargo_table_enhanced(frame)

    -- ========================================
    -- 1. Process and prioritize arguments
    -- ========================================
    local args = getArgs(frame)
    local parent_args = frame:getParent().args

    -- Required configuration
    local cargo_table = args.cargo_table
    local cargo_fields = args.query_fields or args.cargo_fields
    local display_fields_raw = args.table_columns or args.cargo_display_fields or args.cargo_fields
    local cargo_focal_field = args.cargo_focal_field
    local format = args.format or "table"

    -- Display behavior
    local data_row_cutoff = tonumber(args.row_cutoff or parent_args.row_cutoff) or 3
    local row_cutoff = data_row_cutoff + 1
    local character_cutoff = tonumber(args.character_cutoff or parent_args.character_cutoff) or 600

    -- ========================================
    -- 2. Schema lookup
    -- ========================================
    local cargo_table_schema = utils.get_cargo_table_schema(cargo_table)
    local cargo_focal_field_info = cargo_table_schema[cargo_focal_field]

    -- Validate field exists
    if not cargo_focal_field_info then
        error("Field '" .. cargo_focal_field .. "' not found in the Cargo table schema. Did you pass a fully qualified name like 'Table.Field'? Use just the field name.")
    end

    local cargo_focal_field_type = cargo_focal_field_info.type

    -- ========================================
    -- 3. Value sanitization helpers
    -- ========================================
    local function sanitize_values(value_string)
        if not value_string or value_string == "" then return {} end
        local values = mw.text.split(value_string, ",")
        for i, val in ipairs(values) do
            values[i] = utils.escape_string_for_sql(val)
        end
        return values
    end

    local function sanitize_literal(value)
        if not value then return "" end
        return utils.escape_string_for_sql(value)
    end

    local function clean_list(list)
        local cleaned = {}
        for _, val in ipairs(list or {}) do
            if val and mw.text.trim(val) ~= "" then
                table.insert(cleaned, val)
            end
        end
        return cleaned
    end

    -- ========================================
    -- 4. Determine values and literal flags
    -- ========================================
    local page_title = mw.title.getCurrentTitle().text
    local fallback_value = sanitize_literal(page_title)

    local raw_filter_values = args.filter_value or args.filter_values or parent_args.filter_value or parent_args.filter_values or fallback_value
    local raw_not_values = args.not_value or args.not_values or parent_args.not_value or parent_args.not_values

    local filter_value_literal = args.filter_value_literal == "true" or parent_args.filter_value_literal == "true"
    local not_value_literal = args.not_value_literal == "true" or parent_args.not_value_literal == "true"

    local filter_values_list = filter_value_literal
        and { sanitize_literal(raw_filter_values) }
        or sanitize_values(raw_filter_values)

    local not_values_list = not_value_literal
        and { sanitize_literal(raw_not_values) }
        or sanitize_values(raw_not_values)

    -- Remove any empty strings that would break SQL
    filter_values_list = clean_list(filter_values_list)
    not_values_list = clean_list(not_values_list)

    -- ========================================
    -- 5. Parse display fields
    -- ========================================
    local display_fields = mw.text.split(display_fields_raw, ",")
    for i, v in ipairs(display_fields) do
        display_fields[i] = mw.text.trim(v)
    end

    -- ========================================
    -- 6. Column label mapping
    -- ========================================
    local column_label_map = {}
    local column_labels_raw = args.column_labels or parent_args.column_labels
    if column_labels_raw then
        for pair in mw.text.gsplit(column_labels_raw, ",") do
            local key, value = pair:match("([^=]+)=([^=]+)")
            if key and value then
                column_label_map[mw.text.trim(key)] = mw.text.trim(value)
            end
        end
    end

    -- ========================================
    -- 7. Default file fields
    -- ========================================
    local default_file_fields = {}
    local default_files_raw = args.default_file_fields or parent_args.default_file_fields
    if default_files_raw then
        for pair in mw.text.gsplit(default_files_raw, ";") do
            local field, file = pair:match("([^:]+):%s*(.+)")
            if field and file then
                default_file_fields[mw.text.trim(field)] = mw.text.trim(file)
            end
        end
    end

    -- ========================================
    -- 8. Run query
    -- ========================================
    local where_clause = utils.build_where_clause(
        cargo_focal_field_type, cargo_focal_field, filter_values_list, not_values_list
    )
    local cargo_args = { where = where_clause }
    local cargo_results = cargo.query(cargo_table, cargo_fields, cargo_args)

    -- Special format: count only
    if format == "count" then
        return tostring(#cargo_results)
    end

    -- ========================================
    -- 9. Parse results
    -- ========================================
    local parsed = utils.parse_cargo_results(cargo_results, cargo_table_schema)

    -- ========================================
    -- 10. Normalize file fields
    -- ========================================
    if default_file_fields then
        for _, row in ipairs(parsed.results) do
            for field, default_file in pairs(default_file_fields) do
                local schema = cargo_table_schema[field]
                if schema and schema.type == "file" and (row[field] == "" or row[field] == nil) then
                    row[field] = default_file
                elseif schema and schema.type == "list_of_file" and (not row[field] or #row[field] == 0) then
                    row[field] = { default_file }
                end
            end
        end
    end

    -- ========================================
    -- 11. Collapse logic
    -- ========================================
    local collapse = false
    if row_cutoff and #parsed.results > row_cutoff then
        collapse = true
    end

    -- ========================================
    -- 12. Render
    -- ========================================
    if #parsed.results > 0 then
        if format == "bulleted_list" then
            local field_name = display_fields[1]
            return utils.bulleted_list(parsed.results, field_name, cargo_table_schema, column_label_map)
        else
            return utils.generate_wiki_table_enhanced(
                parsed.results,
                display_fields,
                collapse,
                character_cutoff,
                cargo_table_schema,
                column_label_map,
                false,
                nil,
                row_cutoff
            ) .. '<div style="clear: both;"></div>'
        end
    else
        return "No results found in the database at this time—[[HopperWiki:About|please reach out]] if you have any to share!"
    end
end





-- ================================================================================
-- Cargo querying function
-- ================================================================================
function p.filter_cargo_table(frame)
    
    -- ========================================
    -- Process and prioritize arguments
    -- ========================================
    local args = getArgs(frame)
    local parent_args = frame:getParent().args
    
    local title = mw.title.getCurrentTitle().text
    title = title:match("^%s*(.-)%s*$") -- Trim leading/trailing whitespace
    title = utils.escape_string_for_sql(title)
    
    -- Get arguments, allowing for variant spellings and fallbacks
    local filter_values = args.filter_value or args.filter_values or parent_args.filter_value or parent_args.filter_values or title
    local not_values = args.not_value or args.not_values or parent_args.not_value or parent_args.not_values
    local cargo_table = args.cargo_table
    local cargo_fields = args.cargo_fields
    local cargo_display_fields = args.cargo_display_fields or args.cargo_fields
    
    -- Get table schema and validate
    local cargo_table_schema, schema_debug = utils.get_cargo_table_schema(cargo_table)
    if not cargo_table_schema then
        error("Failed to retrieve schema for Cargo table '" .. cargo_table .. "'. Debug info:\n" .. (schema_debug or ""))
    end

    -- Focal field and type
    local cargo_focal_field = args.cargo_focal_field or parent_args.cargo_focal_field
    local cargo_focal_field_info = cargo_table_schema[cargo_focal_field]
    local cargo_focal_field_type = cargo_focal_field_info and cargo_focal_field_info.type or "string"
    
    -- Parse display fields
    if type(cargo_display_fields) == "string" then
        cargo_display_fields = mw.text.split(cargo_display_fields, ",")
        for i, value in ipairs(cargo_display_fields) do
            cargo_display_fields[i] = mw.text.trim(value)
        end
    end

    -- Row and character cutoff values
    local row_cutoff = tonumber(args.row_cutoff or parent_args.row_cutoff)
    local character_cutoff = tonumber(args.character_cutoff or parent_args.character_cutoff)

    -- Split and trim filter values
    local filter_values_list = mw.text.split(filter_values, ",")
    for i, value in ipairs(filter_values_list) do
        filter_values_list[i] = mw.text.trim(value)
    end

    local not_values_list = {}
    if not_values then
        not_values_list = mw.text.split(not_values, ",")
        for i, value in ipairs(not_values_list) do
            not_values_list[i] = mw.text.trim(value)
        end
    end

    -- ========================================
    -- Run Cargo query and format
    -- ========================================
    local where_clause = utils.build_where_clause(cargo_focal_field_type, cargo_focal_field, filter_values_list, not_values_list)
    local cargo_args = { where = where_clause }
    local cargo_results = cargo.query(cargo_table, cargo_fields, cargo_args)
    

	local field_formats = {}
	for field, def in pairs(cargo_table_schema) do
	    field_formats[field] = def.type
	end
	
	-- Generate user-friendly labels by replacing underscores with spaces
	local field_name_mappings = {}
	for _, field in ipairs(cargo_display_fields) do
	    field_name_mappings[field] = string.gsub(field, "_", " ")
	end
	
	local cargo_results_formatted = utils.format_cargo_results(
	    cargo_results,
	    field_formats,
	    field_name_mappings
	)


    -- Determine if display should collapse
    local collapse = false
    if row_cutoff and #cargo_results_formatted > row_cutoff then
        collapse = true
    end

    -- ========================================
    -- Produce display table
    -- ========================================
    local header = ""
    if #cargo_results_formatted > 0 then
        return utils.generate_wiki_table_enhanced_new_collapse(
		    parsed.results,
		    display_fields,
		    collapse,
		    character_cutoff,
		    cargo_table_schema,
		    column_label_map,
		    false, -- compat_mode
		    nil,   -- file_display_format
		    row_cutoff
		) .. '<div style="clear: both;"></div>'
    else
        return "No results found in the database at this time—[[HopperWiki:About|please reach out]] if you have any to share!"
    end
end




-- ================================================================================
-- Custom Cargo wrapper
-- ================================================================================
--[[This function is a base-level wrapper for Cargo to deal with future-proofing the need to modify raw Cargo queries
on the forward facing pages of the wiki. It should be used for more complex SQL querying. If only a simple SQL query is needed,
for instance querying a single keyword (or 
a few keywords) from a single field from a single table then see: filter_cargo_table()--]]
function p.cargo_query(frame)
    -- ========================================
    -- 1. Process and prioritize arguments
    -- ========================================
    local args = getArgs(frame)
    local parent_args = frame:getParent().args

    local cargo_table = args.cargo_table
    local cargo_fields = args.cargo_fields
    local display_fields_raw = args.cargo_display_fields or args.cargo_fields
    local cargo_where = args.cargo_where

    -- Parse options table (JSON string)
    local options = args.options and mw.text.jsonDecode(args.options) or {}

    -- Add fallback defaults—CSS logic for toggle requires header count offset
    local row_cutoff = (tonumber(args.row_cutoff or parent_args.row_cutoff) or 3) + 1
    local character_cutoff = tonumber(args.character_cutoff or parent_args.character_cutoff) or 600

    -- ========================================
    -- 2. Schema lookup
    -- ========================================
    local cargo_table_schema = utils.get_cargo_table_schema(cargo_table)

    -- ========================================
    -- 3. Parse display fields
    -- ========================================
    local display_fields = mw.text.split(display_fields_raw, ",")
    for i, v in ipairs(display_fields) do
        display_fields[i] = mw.text.trim(v)
    end

    -- ========================================
    -- 4. Column label mapping
    -- Handles field renaming like `CONCAT(...) = URL` or custom labels
    -- ========================================
    local column_label_map = {}
    for _, field in ipairs(display_fields) do
        if field:find("=") then
            local expr, label = field:match("(.+)%s*=%s*(.+)")
            if expr and label then
                column_label_map[label] = label
                for i, orig in ipairs(display_fields) do
                    if orig == field then
                        display_fields[i] = label
                    end
                end
            end
        else
            column_label_map[field] = string.gsub(field, "_", " ")
        end
    end

    -- ========================================
    -- 5. Run query
    -- ========================================
    local cargo_args = { where = cargo_where }
    local cargo_results = cargo.query(cargo_table, cargo_fields, cargo_args)

    -- ========================================
    -- 6. Parse structured results
    -- ========================================
    local parsed = utils.parse_cargo_results(cargo_results, cargo_table_schema)

    -- ========================================
    -- 6b. Transform results (e.g. URL → link text)
    -- ========================================
    parsed.results = utils.transform_link_fields(
        parsed.results,
        display_fields,
        cargo_table_schema,
        options
    )

    -- ========================================
    -- 7. Collapse logic
    -- ========================================
    local collapse = false
    if row_cutoff and #parsed.results > row_cutoff then
        collapse = true
    end

    -- ========================================
    -- 8. Output
    -- ========================================
    if #parsed.results > 0 then
        return utils.generate_wiki_table_enhanced(
            parsed.results,
            display_fields,
            collapse,
            character_cutoff,
            cargo_table_schema,
            column_label_map,
            true,   -- compat_mode
            nil,    -- file_display_format
            row_cutoff,
            options  -- still passed, but ignored by renderer
        ) .. '<div style="clear: both;"></div>'
    else
        return "No results found in the database at this time—[[HopperWiki:About|please reach out]] if you have any to share!"
    end
end


p.cargo = p.cargo_query
p.cargo_enhanced = p.cargo_query







return p
Cookies help us deliver our services. By using our services, you agree to our use of cookies.