Jump to content

Module:Cargo query

From HopperWiki

This module is the single entry point for all Cargo queries on HopperWiki. It wraps mw.ext.cargo.query() with schema-aware WHERE clause construction, automatic type detection, result parsing, deduplication, and table rendering.

The primary function is p.query (aliased as p.q). p.resource_query is a human-friendly wrapper over it for the Resource table. The older functions — filter_cargo_table_enhanced, filter_cargo_table, cargo_query, and thin_cargo_wrapper — have been removed; every template that called them has been migrated to p.query.

Architecture

The query pipeline runs in a fixed order regardless of which tier is used.

Stage 1 · Arguments Stage 2 · WHERE clause Stage 3 · Query + parse Stage 4 · Render

Resolve args from frame and parent frame. Apply alias fallbacks. Build label map with automatic underscore→space defaults.

Detect tier from args present. Build WHERE clause via schema-aware escaping. HOLDS vs = determined from field type.

Run cargo.query(). Parse raw results into typed Lua structures via parse_cargo_results(). Apply file fallbacks. Deduplicate. Transform URL fields.

Evaluate collapse threshold. Render via generate_wiki_table_enhanced(), bulleted_list(), or return count string.

Tiers

The WHERE clause tier is detected automatically from which arguments are present. You never declare which tier you are using — the module infers it.

Tier Detected when Use case
1 · Single field filter_field is present and filters and where are absent One focal field, one or more include values, optional excludes against the same field
2 · Multi-field structured filters is present and where is absent Multiple fields composed with AND; values within a field composed with OR; schema-aware escaping applied automatically
3 · Raw WHERE where is present Full escape hatch — passed directly to Cargo. Use when Tier 2 cannot express the logic (date comparisons, LIKE, IS NULL) or when a value must be a template parameter substituted before #invoke runs

Tier detection priority: where wins over filters wins over filter_field. If none are present the query returns all rows — the caller's responsibility.

Parameters

Required

Parameter Notes
table Cargo table name. Alias: cargo_table
fields Comma-separated fields to fetch from Cargo. Aliases: cargo_fields, query_fields

Display

Parameter Default Notes
display_fields Same as fields Fields to show as columns. Fetch a field in fields but omit it here to filter on it without displaying it. Aliases: cargo_display_fields, table_columns
column_labels Underscore→space for every field Override labels for specific columns only. Format: Field_name=My label, Other_field=Other label. Fields not listed get automatic labels — you do not need to list every field.
format table table, bulleted_list, or count. count short-circuits before any rendering and returns a plain integer string.
row_cutoff 3 Rows visible before the table collapses. The CSS class row-cutoff-N is applied at N+1 internally.
character_cutoff 600 Maximum characters per cell before truncation with ellipsis. Not applied to file or list_of_file fields.
default_file_fields — Fallback image for empty file fields. Format: Field: File.jpg; OtherField: Default.png. Semicolon-separated.
options — JSON string passed to transform_link_fields(). Supports link_text and link_text_fields keys.

Tier 1 — single field

Parameter Default Notes
filter_field — The field to filter on. Must be the bare field name as declared in the Cargo schema — not Table.Field. Alias: cargo_focal_field
filter_values Current page title Comma-separated values to include. Alias: filter_value
exclude_values — Comma-separated values to exclude from the same field. Aliases: not_values, not_value

Tier 2 — multi-field structured

Parameter Notes
filters Semicolon-separated filter clauses. Each clause is one of: Field HOLDS value1, value2 · Field = value · Field != value. Clauses are joined with AND. Multiple values within a HOLDS clause are joined with OR. Schema-aware escaping is applied automatically.
excludes Semicolon-separated exclusion clauses in the same syntax. Each becomes an AND NOT fragment appended after the main filters.

Tier 3 — raw WHERE

Parameter Notes
where Passed directly to cargo.query() as the WHERE clause. No escaping applied. Template parameter substitution (e.g. Identification) happens before #invoke runs, making this the correct tier when a value must be caller-configurable.

Query modifiers (all tiers)

Parameter Notes
order_by ORDER BY clause. Example: Year_published DESC. Note: Year_published is a string field — ordering is lexicographic. Store years as zero-padded four-digit strings for correct sort behavior.
group_by GROUP BY clause.
limit Hard cap on rows returned from Cargo before any deduplication.

Clause syntax reference

Tier 2 clause fragments and how they compile:

Input clause Field type Compiled WHERE fragment
Species_purview HOLDS Melanoplus list_of_string Species_purview HOLDS 'Melanoplus'
Species_purview HOLDS Melanoplus, Chorthippus list_of_string (Species_purview HOLDS 'Melanoplus' OR Species_purview HOLDS 'Chorthippus')
Language = French list_of_string Language HOLDS 'French' (auto-upgraded to HOLDS because field is a list type)
Language = French string Language = 'French'
Language != French list_of_string NOT (Language HOLDS 'French')
Language != French string Language != 'French'
Year_published > 2000 any Passed through unchanged (no recognized operator — treated as raw fragment)

Multiple clauses in filters are joined: (...) AND (...) AND (...)

Multiple clauses in excludes each become: AND NOT (...)

Examples

Tier 1: single field, page title as filter

{{#invoke:Cargo_query|query
| table         = Resource
| fields        = Name, Year_published, Resource_link, Author
| filter_field  = All_geography
| row_cutoff    = 3
}}

Fetches resources where All_geography HOLDS Cargo query. Column labels default to Name, Year published, Resource link, Author — no column_labels needed.

Tier 1: with explicit filter value and excludes

{{#invoke:Cargo_query|query
| table          = Resource
| fields         = Name, Year_published, Resource_link, Language
| filter_field   = Species_purview
| filter_values  = Melanoplus devastator
| exclude_values = Unknown
| row_cutoff     = 5
}}

Tier 2: AND across two list fields

{{#invoke:Cargo_query|query
| table    = Resource
| fields   = Name, Long_title, Year_published, Resource_link, Author, Language, Descriptive_keyword
| display_fields = Name, Long_title, Year_published, Resource_link, Author, Language
| filters  = Species_purview HOLDS {{PAGENAME}}; Descriptive_keyword HOLDS Identification
| excludes = Language = Chinese; Language = Japanese
| order_by = Year_published DESC
| row_cutoff = 5
}}

Descriptive_keyword is fetched but excluded from display_fields — it drives the filter without appearing as a column.

Tier 2: geographic region + category

{{#invoke:Cargo_query|query
| table    = Resource
| fields   = Name, Long_title, Year_published, Resource_link, Author, Category
| filters  = All_geography HOLDS {{PAGENAME}}; Category = Media
| order_by = Year_published DESC
| limit    = 10
| row_cutoff = 5
}}

Tier 3: raw WHERE with template parameter

{{#invoke:Cargo_query|query
| table  = Resource
| fields = Name, Year_published, Resource_link
| where  = Species_purview HOLDS '{{PAGENAME}}' AND Descriptive_keyword HOLDS '{{{keyword|Identification}}}'
}}

Use Tier 3 when a value must be a caller-configurable template parameter. The Identification substitution happens before #invoke runs, which Tier 2 cannot accommodate.

Count format for conditional section display

{{#ifexpr: {{#invoke:Cargo_query|query
| table        = Resource
| fields       = Name
| filter_field = Species_purview
| format       = count
}} > 0 | {{Species resources}} }}

Post-query pipeline

After cargo.query() returns, results pass through these steps in order:

  1. Parse — parse_cargo_results() splits list fields into Lua arrays using the delimiter declared in the Cargo schema (~~ for most HopperWiki list fields). Scalar fields remain strings.
  2. File fallbacks — Empty file and list_of_file fields are replaced with the value from default_file_fields if provided.
  3. Deduplication — deduplicate_rows() removes duplicate rows across the display fields.
  4. Link transformation — transform_link_fields() rewrites url and list_of_url fields as labeled external links.
  5. Collapse — If row count ≥ row_cutoff + 1, the CSS class collapsible-cargo-table row-cutoff-N is applied.

Fetch-but-don't-display pattern

Any field listed in fields but absent from display_fields is available to the WHERE clause but does not appear as a column. This is the standard approach for filter-only fields:

| fields         = Name, Year_published, Descriptive_keyword
| display_fields = Name, Year_published
| filters        = Descriptive_keyword HOLDS Identification

Descriptive_keyword drives the filter. It never appears in the rendered table.

Known gotchas

Situation What happens Fix
filter_field = Table.Field Schema lookup fails — field not found Use bare field name only: filter_field = Species_purview
Comma in a filter value (Tier 2 HOLDS) Value is split at the comma, producing two filter tokens Move to Tier 3 and write the WHERE clause manually
Year_published DESC with non-numeric values Lexicographic sort — "circa 1987" or "unknown" sort unpredictably Store years as plain four-digit strings
limit applied before deduplication Cargo caps rows before the pipeline runs — deduplication may reduce the final count below limit Set limit generously if deduplication is expected to remove rows
column_labels listing every field with Name=Name style entries Unnecessary — automatic underscore→space handles this Omit column_labels entirely, or list only fields that need a genuinely custom label
Tier 3 with unescaped user input SQL injection risk Never pass raw user-supplied values into where=. Use Tier 1 or Tier 2 for user-supplied values.

Removed functions

filter_cargo_table_enhanced, filter_cargo_table, cargo_query (and its aliases p.cargo / p.cargo_enhanced), and thin_cargo_wrapper have been removed from this module. All templates that called them were migrated to p.query:

Old function Replacement
filter_cargo_table_enhanced p.query with filter_field
cargo_query / p.cargo / p.cargo_enhanced p.query with filters or where
thin_cargo_wrapper p.query with filters or where
filter_cargo_table Was dead code (scope errors, never callable) — deleted outright

Module family

Module:Cargo_query is one of several modules in the HopperWiki utilities family. It depends on:

Module Role
Module:Cargo query utilities Schema parsing, WHERE clause construction, row fetching
Module:Cargo format utilities Result parsing, list splitting, URL and file formatting
Module:Wiki output utilities Table and list rendering (generate_wiki_table_enhanced, bulleted_list)
Module:Utilities Re-exports all of the above for backward compatibility
Module:Arguments Frame argument processing

-- ================================================================================
-- 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 str = require("Module:String utilities")
local tbl = require("Module:Table utilities")
local cq = require("Module:Cargo query utilities")
local cfmt = require("Module:Cargo format utilities")
local out = require("Module:Wiki output utilities")


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


-- ================================================================================
-- Unified Cargo query entry point
-- ================================================================================
--[[
    p.query(frame)

    The single entry point for Cargo queries in this module (older wrappers —
    filter_cargo_table_enhanced, filter_cargo_table, cargo_query, and
    thin_cargo_wrapper — have been removed; see doc.lua for the migration
    mapping). The WHERE clause tier is detected automatically from which
    arguments are present. Everything else (schema lookup, parsing,
    rendering) runs through a single shared pipeline.

    ── Tier 1: single focal field ───────────────────────────────────────────────
    Simplest case. One field, one or more include values, optional excludes.
    Field type (HOLDS vs =) is detected automatically from the Cargo schema.

        {{#invoke:Cargo_query|query
        | table          = Resource
        | fields         = Name, Year_published, Resource_link, Author
        | filter_field   = Species_purview
        | filter_values  = {{PAGENAME}}
        | exclude_values = Unknown
        }}

    ── Tier 2: multiple fields, structured ──────────────────────────────────────
    Multiple independent filter clauses, separated by semicolons.
    Each clause is:  FieldName HOLDS value1, value2
                 or  FieldName = value
                 or  FieldName != value
    Clauses are composed with AND between fields.
    Multiple values within a single HOLDS clause are composed with OR.
    Schema-aware escaping is applied automatically to every value.

    Use |excludes= for field-level exclusions in the same syntax.

        {{#invoke:Cargo_query|query
        | table    = Resource
        | fields   = Name, Year_published, Resource_link, Author
        | filters  = Species_purview HOLDS {{PAGENAME}}; Geographic_purview HOLDS North America
        | excludes = Language = French
        }}

    ── Tier 3: raw WHERE clause ─────────────────────────────────────────────────
    Full escape hatch. The value of |where= is passed directly to Cargo.
    No escaping or schema-awareness is applied. Use when Tier 1/2 cannot
    express the logic you need (e.g. date comparisons, LIKE, subqueries).

        {{#invoke:Cargo_query|query
        | table  = Resource
        | fields = Name, Year_published
        | where  = Species_purview HOLDS '{{PAGENAME}}' AND Year_published > 2000
        }}

    ── Common parameters (all tiers) ────────────────────────────────────────────
    table           (required) Cargo table name
    fields          (required) Comma-separated fields to fetch
    display_fields  Fields to show in output. Defaults to |fields|.
    column_labels   Override display labels for specific fields only.
                    Format: FieldName=My Label, OtherField=Other Label
                    Fields not listed here get automatic underscore→space labels.
                    You do NOT need to list fields whose label matches the default.
    order_by        ORDER BY clause, e.g. "Year_published DESC"
    group_by        GROUP BY clause
    limit           Hard cap on results returned from Cargo
    row_cutoff      Rows before the table collapses (default 3)
    character_cutoff Max characters per cell before truncation (default 600)
    format          "table" (default), "bulleted_list", or "count"
    default_file_fields  Fallback images for empty file fields.
                         Format: FieldName: File.jpg; OtherField: Default.png
    options         JSON string for advanced link rendering (passed to
                    transform_link_fields)

    ── Backward-compatible aliases ───────────────────────────────────────────────
    |table|         = |cargo_table|
    |fields|        = |cargo_fields| = |query_fields|
    |display_fields|= |cargo_display_fields| = |table_columns|
    |filter_field|  = |cargo_focal_field|
    |filter_values| = |filter_value|
    |exclude_values|= |not_values| = |not_value|
--]]
function p.query(frame)

    -- =========================================================================
    -- 0. Internal helpers (scoped to this function)
    -- =========================================================================

    -- Split a semicolon-delimited string into trimmed, non-empty parts.
    local function split_semi(s)
        if not s or s == "" then return {} end
        local parts = {}
        for chunk in mw.text.gsplit(s, ";") do
            local t = mw.text.trim(chunk)
            if t ~= "" then table.insert(parts, t) end
        end
        return parts
    end

    -- Split a comma-delimited string into trimmed, non-empty parts.
    local function split_comma(s)
        if not s or s == "" then return {} end
        local parts = {}
        for chunk in mw.text.gsplit(s, ",") do
            local t = mw.text.trim(chunk)
            if t ~= "" then table.insert(parts, t) end
        end
        return parts
    end

    -- Return true when the schema type warrants HOLDS rather than =.
    local function is_list_type(field_type)
        return field_type == "list_of_string"
            or field_type == "list_of_page"
            or field_type == "list_of_url"
            or field_type == "list_of_file"
    end

    --[[
    Build one fragment of a WHERE clause for a single field.

    clause_str examples accepted:
        "Species_purview HOLDS Melanoplus devastator, Chorthippus"
        "Species_purview HOLDS Melanoplus devastator"
        "Language = French"
        "Language != French"
        "Year_published > 2000"          -- passed through as-is (Tier 3 escape)

    For HOLDS and = / != clauses the values are escaped via the schema-aware
    helpers already present in Module:String_utilities. Anything else
    (>, <, LIKE, IS NULL …) is passed through unchanged so Tier 3 raw
    fragments still work when called from the structured tier.
    --]]
    local function build_clause_fragment(clause_str, schema)
        -- HOLDS  FieldName HOLDS value1, value2
        local field_h, values_h = clause_str:match("^(%S+)%s+HOLDS%s+(.+)$")
        if field_h and values_h then
            local field_info = schema[field_h]
            local fragments  = {}
            for _, v in ipairs(split_comma(values_h)) do
                local safe = str.escape_string_for_holds(v)
                table.insert(fragments, string.format("%s HOLDS '%s'", field_h, safe))
            end
            -- Multiple values within one HOLDS clause = OR
            return "(" .. table.concat(fragments, " OR ") .. ")"
        end

        -- !=  FieldName != value
        local field_neq, value_neq = clause_str:match("^(%S+)%s*!=%s*(.+)$")
        if field_neq and value_neq then
            local field_info = schema[field_neq]
            local ftype      = field_info and field_info.type or "string"
            value_neq        = mw.text.trim(value_neq)
            if is_list_type(ftype) then
                local safe = str.escape_string_for_holds(value_neq)
                return string.format("NOT (%s HOLDS '%s')", field_neq, safe)
            else
                local safe = str.escape_string_for_sql(value_neq)
                return string.format("%s != '%s'", field_neq, safe)
            end
        end

        -- =  FieldName = value  (schema-aware: may become HOLDS)
        local field_eq, value_eq = clause_str:match("^(%S+)%s*=%s*(.+)$")
        if field_eq and value_eq then
            local field_info = schema[field_eq]
            local ftype      = field_info and field_info.type or "string"
            value_eq         = mw.text.trim(value_eq)
            if is_list_type(ftype) then
                local safe = str.escape_string_for_holds(value_eq)
                return string.format("%s HOLDS '%s'", field_eq, safe)
            else
                local safe = str.escape_string_for_sql(value_eq)
                return string.format("%s = '%s'", field_eq, safe)
            end
        end

        -- Anything else: pass through unchanged (raw fragment for Tier 3)
        return clause_str
    end

    -- =========================================================================
    -- 1. Argument resolution (aliases + parent frame fallback)
    -- =========================================================================
    local args        = getArgs(frame)
    local parent_args = frame:getParent() and frame:getParent().args or {}

    local function arg(...)
        for _, key in ipairs({...}) do
            local v = args[key] or parent_args[key]
            if v and mw.text.trim(v) ~= "" then return mw.text.trim(v) end
        end
        return nil
    end

    local cargo_table        = arg("table", "cargo_table")
    local cargo_fields_raw   = arg("fields", "cargo_fields", "query_fields")
    local display_fields_raw = arg("display_fields", "cargo_display_fields",
                                   "table_columns", "cargo_fields", "query_fields")
    local format             = arg("format") or "table"
    local order_by           = arg("order_by")
    local group_by           = arg("group_by")
    local limit              = tonumber(arg("limit"))
    local options            = arg("options") and mw.text.jsonDecode(arg("options")) or {}

    local data_row_cutoff  = tonumber(arg("row_cutoff"))   or 3
    local row_cutoff       = data_row_cutoff + 1           -- CSS offset
    local character_cutoff = tonumber(arg("character_cutoff")) or 600

    -- =========================================================================
    -- 2. Schema
    -- =========================================================================
    if not cargo_table then
        error("Module:Cargo_query p.query — |table| is required")
    end
    if not cargo_fields_raw then
        error("Module:Cargo_query p.query — |fields| is required")
    end

    local cargo_fields  = cargo_fields_raw:gsub("%s*,%s*", ",")
    local cargo_table_schema = cq.get_cargo_table_schema(cargo_table)

    -- =========================================================================
    -- 3. Display fields and column labels
    -- =========================================================================
    local display_fields = {}
    for _, f in ipairs(split_comma(display_fields_raw)) do
        table.insert(display_fields, f)
    end

    -- Build label map: start with automatic underscore→space for every field,
    -- then apply caller overrides only for fields they actually specified.
    local column_label_map = {}
    for _, field in ipairs(display_fields) do
        column_label_map[field] = field:gsub("_", " ")
    end

    local column_labels_raw = arg("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
    end

    -- =========================================================================
    -- 4. WHERE clause construction (tier detection)
    -- =========================================================================
    local where_clause

    local raw_where = arg("where")
    local filters   = arg("filters")
    local excludes  = arg("excludes")

    -- Tier 1 args
    local filter_field   = arg("filter_field", "cargo_focal_field")
    local filter_values  = arg("filter_values", "filter_value",
                               "filter_values", "filter_value")
    local exclude_values = arg("exclude_values", "not_values", "not_value")

    if raw_where then
        -- ── Tier 3: raw WHERE, passed through as-is ──────────────────────────
        where_clause = raw_where

    elseif filters then
        -- ── Tier 2: structured multi-field filters ────────────────────────────
        -- Each semicolon-separated clause becomes one AND fragment.
        local and_fragments = {}

        for _, clause in ipairs(split_semi(filters)) do
            table.insert(and_fragments, build_clause_fragment(clause, cargo_table_schema))
        end

        -- Exclusions are additional AND NOT fragments
        if excludes then
            for _, clause in ipairs(split_semi(excludes)) do
                local fragment = build_clause_fragment(clause, cargo_table_schema)
                table.insert(and_fragments, "NOT (" .. fragment .. ")")
            end
        end

        where_clause = table.concat(and_fragments, " AND ")

    elseif filter_field then
        -- ── Tier 1: single focal field ────────────────────────────────────────
        -- Delegate to the existing build_where_clause utility so escaping and
        -- HOLDS detection remain consistent with the rest of the codebase.
        local page_title = mw.title.getCurrentTitle().text

        local filter_values_list
        if filter_values then
            filter_values_list = {}
            for _, v in ipairs(split_comma(filter_values)) do
                table.insert(filter_values_list, str.escape_string_for_sql(v))
            end
        else
            filter_values_list = { str.escape_string_for_sql(page_title) }
        end

        local not_values_list = {}
        if exclude_values then
            for _, v in ipairs(split_comma(exclude_values)) do
                table.insert(not_values_list, str.escape_string_for_sql(v))
            end
        end

        local field_info = cargo_table_schema[filter_field]
        if not field_info then
            error("p.query — filter_field '" .. filter_field
                  .. "' not found in schema for table '" .. cargo_table
                  .. "'. Use the bare field name, not Table.Field.")
        end

        where_clause = cq.build_where_clause(
            field_info.type, filter_field, filter_values_list, not_values_list
        )

    else
        -- No filter at all — returns all rows (caller's responsibility)
        where_clause = nil
    end

    -- =========================================================================
    -- 5. Run the Cargo query
    -- =========================================================================
    local cargo_args = {
        where   = where_clause,
        orderBy = order_by,
        groupBy = group_by,
        limit   = limit,
    }

    local cargo_results = cargo.query(cargo_table, cargo_fields, cargo_args)

    -- ── count format short-circuits before any further processing ────────────
    if format == "count" then
        return tostring(#cargo_results)
    end

    -- =========================================================================
    -- 6. Parse results into typed Lua structures
    -- =========================================================================
    local parsed = cfmt.parse_cargo_results(cargo_results, cargo_table_schema)

    -- =========================================================================
    -- 7. Default file field substitution
    -- =========================================================================
    local default_file_fields = {}
    local default_files_raw = arg("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

        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 then
                    if schema.type == "file"
                        and (row[field] == "" or row[field] == nil) then
                        row[field] = default_file
                    elseif schema.type == "list_of_file"
                        and (not row[field] or #row[field] == 0) then
                        row[field] = { default_file }
                    end
                end
            end
        end
    end

    -- =========================================================================
    -- 8. Deduplication
    -- =========================================================================
    parsed.results = tbl.deduplicate_rows(parsed.results, display_fields)

    -- =========================================================================
    -- 9. Link field transformation
    -- =========================================================================
    parsed.results = cfmt.transform_link_fields(
        parsed.results, display_fields, cargo_table_schema, options
    )

    -- =========================================================================
    -- 10. Collapse logic
    -- =========================================================================
    local collapse = (#parsed.results >= row_cutoff)

    -- =========================================================================
    -- 11. Render
    -- =========================================================================
    if #parsed.results == 0 then
        return "No results found in the database at this time—"
            .. "[[HopperWiki:About|please reach out]] if you have any to share!"
    end

    if format == "bulleted_list" then
        return out.bulleted_list(
            parsed.results, display_fields[1], cargo_table_schema, column_label_map
        )
    end

    -- Default: table
    return out.generate_wiki_table_enhanced(
        parsed.results,
        display_fields,
        collapse,
        character_cutoff,
        cargo_table_schema,
        column_label_map,
        false,       -- compat_mode: always false; schema is always present here
        nil,         -- file_display_format: use renderer default
        row_cutoff,
        options
    )
end


-- Alias so {{#invoke:Cargo_query|q|...}} works as a shorter form
p.q = p.query




-- ================================================================================
-- Resource table query — user-facing wrapper over p.query
-- ================================================================================
--[[
    p.resource_query(frame)

    A clean, human-friendly entry point for querying the Resource Cargo table.
    Handles all field list construction in Lua so the template stays thin.
    Delegates to the same shared pipeline as p.query once fields and WHERE
    clause are resolved.

    Parameters:

    -- Filter (which field to match against)
    filter          "geography" (default) | "species" | "category" | "keyword" | "language"
    filter_value    Value to filter on. Defaults to {{PAGENAME}} for geography and species.
                    Required for category, keyword, language.
    exclude         Semicolon-separated exclusion clauses in Tier 2 syntax.
                    Example: "Category = Media; Language = French"

    -- Column toggles (all off by default except name, year, link)
    show_name       Always on. Cannot be disabled.
    show_year       on by default
    show_link       on by default
    show_title      Long title
    show_language   Language
    show_category   Category
    show_keywords   Descriptive keyword
    show_author     Author
    show_file       File
    show_geography  Geographic purview
    show_species    Species purview
    show_description Resource description
    show_project    Project
    show_copyright  Copyright type

    -- Query modifiers
    sort            Cargo field name to sort by (default: Year_published)
    sort_order      ASC or DESC (default: DESC)
    limit           Hard cap on results
    row_cutoff      Rows before collapse (default: 3)
    character_cutoff Max characters per cell (default: 600)
    format          "table" (default) | "bulleted_list" | "count"
--]]
function p.resource_query(frame)

    -- =========================================================================
    -- 1. Arguments
    -- =========================================================================
    local args        = getArgs(frame)
    local parent_args = frame:getParent() and frame:getParent().args or {}

    local function arg(...)
        for _, key in ipairs({...}) do
            local v = args[key] or parent_args[key]
            if v and mw.text.trim(v) ~= "" then return mw.text.trim(v) end
        end
        return nil
    end

    local function is_on(key, default_on)
        local v = arg(key)
        if v == nil then return default_on or false end
        return v == "yes" or v == "true" or v == "1"
    end

    -- =========================================================================
    -- 2. Column selection
    -- Name, Year, Link are on by default. Everything else is opt-in.
    -- =========================================================================

    -- Each entry: { field = "Cargo_field_name", label = "Display label", default = true/false }
    local column_defs = {
        { field = "Name",                 label = "Name",         toggle = nil,                default_on = true  },
        { field = "Long_title",           label = "Title",        toggle = "show_title",       default_on = false },
        { field = "Year_published",       label = "Year",         toggle = "show_year",        default_on = true  },
        { field = "Language",             label = "Language",     toggle = "show_language",    default_on = false },
        { field = "Category",             label = "Category",     toggle = "show_category",    default_on = false },
        { field = "Descriptive_keyword",  label = "Keywords",     toggle = "show_keywords",    default_on = false },
        { field = "Author",               label = "Author",       toggle = "show_author",      default_on = false },
        { field = "Resource_link",        label = "Link",         toggle = "show_link",        default_on = true  },
        { field = "File_name",            label = "File",         toggle = "show_file",        default_on = false },
        { field = "Geographic_purview",   label = "Geography",    toggle = "show_geography",   default_on = false },
        { field = "Species_purview",      label = "Species",      toggle = "show_species",     default_on = false },
        { field = "Resource_description", label = "Description",  toggle = "show_description", default_on = false },
        { field = "Project",              label = "Project",      toggle = "show_project",     default_on = false },
        { field = "Copyright_type",       label = "Copyright",    toggle = "show_copyright",   default_on = false },
        { field = "OTU_ID",          label = "OTU ID",          toggle = "show_otu_id",          default_on = false },
		{ field = "All_geography",   label = "All geography",    toggle = "show_all_geography",   default_on = false },
		{ field = "Substance",       label = "Substance",        toggle = "show_substance",       default_on = false },
		{ field = "Substance_rating",label = "Substance rating", toggle = "show_substance_rating",default_on = false },
    }

    local display_fields  = {}
    local column_label_map = {}

    for _, col in ipairs(column_defs) do
        local show = col.toggle == nil and true or is_on(col.toggle, col.default_on)
        if show then
            table.insert(display_fields, col.field)
            column_label_map[col.field] = col.label
        end
    end

    -- =========================================================================
    -- 3. Filter field — always fetched even if not displayed
    -- =========================================================================
    local filter_mode = string.lower(arg("filter") or "geography")
    local filter_value = arg("filter_value") or mw.title.getCurrentTitle().text
    local exclude_raw  = arg("exclude")

    -- Map filter mode to Cargo field name
    local filter_field_map = {
        geography = "All_geography",
        species   = "Species_purview",
        category  = "Category",
        keyword   = "Descriptive_keyword",
        language  = "Language",
    }

    local filter_field = filter_field_map[filter_mode]
    if not filter_field then
        error("p.resource_query — unknown filter mode '" .. filter_mode
              .. "'. Valid values: geography, species, category, keyword, language")
    end

    -- Build the fetch fields list: display fields + filter field (if not already included)
    local fetch_fields = {}
    local fetch_set    = {}
    for _, f in ipairs(display_fields) do
        table.insert(fetch_fields, f)
        fetch_set[f] = true
    end
    if not fetch_set[filter_field] then
        table.insert(fetch_fields, filter_field)
    end

    -- =========================================================================
    -- 4. WHERE clause
    -- Uses the same build_where_clause utility as p.query tier 1 for the
    -- main filter, then appends exclude clauses from the Tier 2 parser.
    -- =========================================================================
    local cargo_table_schema = cq.get_cargo_table_schema("Resource")

    local field_info = cargo_table_schema[filter_field]
    if not field_info then
        error("p.resource_query — filter field '" .. filter_field .. "' not found in Resource schema")
    end

    -- Main filter
    local safe_value       = str.escape_string_for_sql(filter_value)
    local where_clause     = cq.build_where_clause(
        field_info.type, filter_field, { safe_value }, {}
    )

    -- Exclusions — reuse the same clause fragment parser from p.query
    local function split_semi(s)
        if not s or s == "" then return {} end
        local parts = {}
        for chunk in mw.text.gsplit(s, ";") do
            local t = mw.text.trim(chunk)
            if t ~= "" then table.insert(parts, t) end
        end
        return parts
    end

    local function is_list_type(field_type)
        return field_type == "list_of_string"
            or field_type == "list_of_page"
            or field_type == "list_of_url"
            or field_type == "list_of_file"
    end

    local function build_exclude_fragment(clause_str)
        -- != clause
        local field_neq, value_neq = clause_str:match("^(%S+)%s*!=%s*(.+)$")
        if field_neq and value_neq then
            local fi    = cargo_table_schema[field_neq]
            local ftype = fi and fi.type or "string"
            value_neq   = mw.text.trim(value_neq)
            if is_list_type(ftype) then
                return string.format("NOT (%s HOLDS '%s')",
                    field_neq, str.escape_string_for_holds(value_neq))
            else
                return string.format("%s != '%s'",
                    field_neq, str.escape_string_for_sql(value_neq))
            end
        end
        -- = clause (treated as exclusion → NOT)
        local field_eq, value_eq = clause_str:match("^(%S+)%s*=%s*(.+)$")
        if field_eq and value_eq then
            local fi    = cargo_table_schema[field_eq]
            local ftype = fi and fi.type or "string"
            value_eq    = mw.text.trim(value_eq)
            if is_list_type(ftype) then
                return string.format("NOT (%s HOLDS '%s')",
                    field_eq, str.escape_string_for_holds(value_eq))
            else
                return string.format("%s != '%s'",
                    field_eq, str.escape_string_for_sql(value_eq))
            end
        end
        -- Passthrough
        return "NOT (" .. clause_str .. ")"
    end

    if exclude_raw then
        local not_fragments = {}
        for _, clause in ipairs(split_semi(exclude_raw)) do
            table.insert(not_fragments, build_exclude_fragment(clause))
        end
        if #not_fragments > 0 then
            where_clause = "(" .. where_clause .. ") AND "
                .. table.concat(not_fragments, " AND ")
        end
    end

    -- =========================================================================
    -- 5. Query modifiers
    -- =========================================================================
    local sort_field  = arg("sort") or "Year_published"
    local sort_order  = arg("sort_order") or "DESC"
    local order_by    = sort_field .. " " .. sort_order
    local limit       = tonumber(arg("limit"))
    local format      = arg("format") or "table"

    local data_row_cutoff  = tonumber(arg("row_cutoff")) or 3
    local row_cutoff       = data_row_cutoff + 1
    local character_cutoff = tonumber(arg("character_cutoff")) or 600

    -- =========================================================================
    -- 6. Run query
    -- =========================================================================
    local cargo_fields_str = table.concat(fetch_fields, ",")
    local cargo_args = {
        where   = where_clause,
        orderBy = order_by,
        limit   = limit,
    }

    local cargo_results = cargo.query("Resource", cargo_fields_str, cargo_args)

    if format == "count" then
        return tostring(#cargo_results)
    end

    -- =========================================================================
    -- 7. Parse, deduplicate, transform
    -- =========================================================================
    local parsed = cfmt.parse_cargo_results(cargo_results, cargo_table_schema)
    parsed.results = tbl.deduplicate_rows(parsed.results, display_fields)
    parsed.results = cfmt.transform_link_fields(
        parsed.results, display_fields, cargo_table_schema, {}
    )

    -- =========================================================================
    -- 8. Render
    -- =========================================================================
    if #parsed.results == 0 then
        return "No results found in the database at this time—"
            .. "[[HopperWiki:About|please reach out]] if you have any to share!"
    end

    local collapse = (#parsed.results >= row_cutoff)

    if format == "bulleted_list" then
        return out.bulleted_list(
            parsed.results, display_fields[1], cargo_table_schema, column_label_map
        )
    end

    return out.generate_wiki_table_enhanced(
        parsed.results,
        display_fields,
        collapse,
        character_cutoff,
        cargo_table_schema,
        column_label_map,
        false,
        nil,
        row_cutoff,
        {}
    )
end


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