Module:CargoUtils

-- Load modules local fun = require("Module:Functional") local iter = fun.iter local op = fun.op

local class = require("Module:Class")

-- Whether the value of a field exists in the Cargo table. local function exists(table_name, field_name, field_value) return (#mw.ext.cargo.query(table_name, field_name, { where = string.format("%s = '%s'", field_name, field_value) } ) > 0) end

-- Whether the value of a field can be a key in the Cargo table. local function isKey(table_name, field_name, field_value) return (#mw.ext.cargo.query(table_name, field_name, { where = string.format("%s = '%s'", field_name, field_value) } ) == 1) end

-- Whether the value of a field is unique in the Cargo table, if it exists. -- If the value does not exist yet, returns `true`. local function isUnique(table_name, field_name, field_value) return (#mw.ext.cargo.query(table_name, field_name, { where = string.format("%s = '%s'", field_name, field_value) } ) <= 1) end

-- Defines a Cargo table. local CargoTable = class("CargoTable")

CargoTable.static.CARGO_FIELD_TYPES = { "Page", "String", "Text", "Integer", "Float", "Date", "Start date", "End date", "Datetime", "Start datetime", "End datetime", "Boolean", "Coordinates", "Wikitext string", "Wikitext", "Searchtext", "File", "URL", "Email", "Rating" }

CargoTable.static.CARGO_FIELD_PARAMS = { ["parametized"] = { "size", "allowed values", "link text", "regex", "dependent on", },	["flags"] = { "hierarchy", "hidden", "mandatory", "unique", }, }

CargoTable.static.SUPPORTED_FIELD_CONSTRAINTS = { "NOT NULL", "UNIQUE", }

CargoTable.static.METADATA_FIELDS = { ["_pageName"] = { "String" }, ["_pageTitle"] = { "String" }, ["_pageNamespace"] = { "Integer" }, ["_pageID"] = { "Integer" }, ["_ID"] = { "Integer" }, }

-- Constructor -- --[[ Creates a new CargoTable with name `name` according to the definition provided by table `body`. - `body` is a table { field_name -> { field_type, { field_param -> param_value }, { field_constraints } }. - `field_type` is a field type defined for the Cargo extension.  - `field_param` is a field parameter defined for the Cargo extension. Flags are activated by setting the corresponding values to `true`.  - `field_constraints` is a list of flags to activate for that field. - `primary_key` is an optional list that includes the field(s) that make up the primary key. This is not supported for fields that are in the form of a list. - `foreign_key` is an optional table { field_name -> { foreign_table_name, foreign_field_name } }. -- If the field is in the form of list, then each element will be subject to the above checks separately.

For example: [MySQL] CREATE TABLE TestTable (   TestID int NOT NULL,    ThisID int,    Name varchar(255) NOT NULL UNIQUE,    PRIMARY KEY (TestID),    FOREIGN KEY (ThisID) REFERENCES OtherTable(OtherID) ) [Lua] local body = { ["TestID"] = { "Integer", nil, { "NOT NULL" }, ["ThisID"] = { "Integer" }, ["Name"] = { "String", { size = 255 }, { "NOT NULL", "UNIQUE" }, } local primary_key = { "TestID" } local foreign_keys = { ["ThisID"] = { "OtherTable", "OtherID" } } local testTable = CargoTable("TestTable", body, primary_key, foreign_keys)

Note that the metadata fields "_pageName", "_pageTitle", "_pageNamespace", "_pageID" and "_ID" are automatically included as well. --]] function CargoTable:initialize(name, body, primary_key, foreign_keys) body = body or {} primary_key = primary_key or {} foreign_keys = foreign_keys or {} -- Check name self._name = assert(name, "The table name must be provided") -- Insert metadata fields into body body = iter(body):chain(CargoTable.METADATA_FIELDS) -- Check body and replace nils with empty table self._body = body:map(function (field_name, field_info)		local field_type = field_info[1]		-- Check field_type		assert(field_type, string.format("field_type is not given for field '%s'", field_name))		local _, element_type = CargoTable:parseElementInfo(field_type)		if element_type then			assert(not iter(primary_key):index(field_name), string.format("Primary key field '%s' cannot be a list", field_name))			assert(iter(CargoTable.CARGO_FIELD_TYPES):index(element_type), string.format("Unknown element type '%s' of list", element_type))		else			assert(iter(CargoTable.CARGO_FIELD_TYPES):index(field_type), string.format("Unknown field type '%s'", field_type))		end		-- Check field_params		field_params = field_info[2] or {}		iter(field_params):each(function (param, value) local is_parameter = iter(CargoTable.CARGO_FIELD_PARAMS.parametized):index(param) local is_flag = iter(CargoTable.CARGO_FIELD_PARAMS.flags):index(param) assert(is_parameter or is_flag, string.format("Unknown field parameter '%s'", param)) end)		-- Check field_constraints		field_constraints = field_info[3] or {}		iter(field_constraints):each(function (constraint) assert(iter(CargoTable.SUPPORTED_FIELD_CONSTRAINTS):index(constraint), string.format("Unknown field constraint '%s'", constraint)) end)		return field_name, {field_type, field_params, field_constraints}	end):tomap -- Check primary_key self._primary_key = primary_key or {} iter(primary_key):each(function (key)		assert(self._body[key], string.format("Attempt to create primary key from missing field '%s'", key))	end) -- Check foreign_keys self._foreign_keys = foreign_keys or {} iter(foreign_keys):each(function (key)		assert(self._body[key], string.format("Attempt to create foreign key from missing field '%s'", key))	end) end

-- Getters & Setters -- -- The name of the Cargo table function CargoTable:getName return self._name end

-- The fields of the Cargo table, including the metadata fields. function CargoTable:getFields return iter(self._body):keys:totable end

-- The fields that make up the primary key of the Cargo table function CargoTable:getPrimaryKey return mw.clone(self._primary_key) end

-- The fields of the Cargo table that are foreign keys function CargoTable:getForeignKeyFields return iter(self._foreign_keys):keys:totable end

-- Static methods -- -- Parses the element delimiter and type if `field_type` indicates a list, returning `nil` otherwise. function CargoTable.static:parseElementInfo(field_type) return field_type:match("List %((.*)%) of (%w*)") end

-- Standardizes a string value for insertion to the table function CargoTable.static:standardizeStringValue_insert(str) -- Standardize numbers n = mw.language.new('en'):parseFormattedNumber(str) if n then str = tostring(n) end -- Escapes all '#' characters in the string as it is not allowed in the parser function `#cargo_store`. return (str:gsub("#", "%%23")) end

-- Standardizes a string for querying from the table function CargoTable.static:standardizeStringValue_query(str) -- Standardize numbers n = tonumber(str) if n then str = tostring(mw.language.new('en'):formatNum(n)) end -- Unscapes all '#' characters in the string, basically the inverse of `standardizeStringValue_insert`. return (str:gsub("%%23", "#")) end

-- Other methods -- -- Returns the output of the #cargo_declare parser function according to the definition of this CargoTable, -- called by the given frame. function CargoTable:declare(frame) assert(frame, "`frame` cannot be nil") if type(frame) ~= type({}) then error(string.format("`frame` is not a table, found it to be: %s", frame)) end local args = iter(self._body):map(function (field_name, field_info)		local field_type, field_params = field_info[1], field_info[2]		local param_substrs = iter(field_params):map(function (param_name, param_value) if iter(CargoTable.CARGO_FIELD_PARAMS.parametized):index(param_name) then return string.format("%s=%s", param_name, param_value) elseif iter(CargoTable.CARGO_FIELD_PARAMS.flags):index(param_name) then if param_value == true then return string.format("%s", param_name) else return false end end end):filter(op.truth):totable		return field_name, string.format("%s (%s)", field_type, table.concat(param_substrs, ';'))	end):tomap -- Metadata fields are not declared in the parse function iter(CargoTable.METADATA_FIELDS):each(function (field_name) args[field_name] = nil end) -- First index is populated to work around the error related to callParserFunction args[1] = '' -- Call parser function return mw.getCurrentFrame:callParserFunction(string.format("#cargo_declare: _table = %s", self._name), args) end

-- Converts a record from Lua objects to string values where appropriate. -- Returns the converted record. function CargoTable:LuaToStringFields(record) return iter(record):map(function (field_name, field_value)		local field_info = self._body[field_name]		local field_type, field_params, field_constraints = field_info[1], field_info[2], field_info[3]		local element_delimiter, element_type = CargoTable:parseElementInfo(field_type)		-- Convert from string to table		if element_delimiter then			field_value = mw.text.split(tostring(field_value), element_delimiter)		end		-- Check field_constraints		iter(field_constraints):each(function (constraint) iter(field_value):flat(1):each(function (v)				if constraint == "UNIQUE" then					assert(v == nil or isUnique(self._name, field_name, v), string.format("The field '%s' is not unique", field_name))				elseif constraint == "NOT NULL" then					assert(v ~= nil, string.format("The field '%s' cannot be null", field_name))				end			end) end)		if field_value ~= nil then			-- Check foreign_key			local foreign_key_info = self._foreign_keys[k]			if foreign_key_info ~= nil then				local foreign_table_name, foreign_field_name = foreign_key_info[1], foreign_key_info[2]				iter(field_value):flat(-1):each(function (v) if not isKey(foreign_table, foreign_field_name, v) then error(string.format("The value %s for field '%s' of this table is not a key in the '%s' field of foreign table '%s'", v, field_name, foreign_field_name, foreign_table_name)) end end)			end			-- Store the value as a string			if element_delimiter and type(field_value) == "table" then				field_value = table.concat(field_value, element_delimiter)			else				if not tostring(field_value) then					error(string.format("The field '%s' failed to convert to a string. Dump: %s", field_name, mw.dumpObject(field_value)))				end			end			field_value = CargoTable:standardizeStringValue_insert(field_value)		end		return field_name, field_value	end):tomap end

-- Converts a record from string values to Lua objects where appropriate. -- Returns the converted record. function CargoTable:stringToLuaFields(record) return iter(record):map(function (field_name, field_value)		-- Convert from string to table		local field_type = self._body[field_name][1]		local element_delimiter, element_type = CargoTable:parseElementInfo(field_type)		local function convert(v)			-- Empty strings are equivalent to nil			if v == "" then return false end			if field_type == "Integer" or field_type == "Float" then				return tonumber(v)			elseif field_type == "Boolean" then				return (v == "1" or v == "yes")			else				return CargoTable:standardizeStringValue_query(v)			end		end		if element_delimiter then			field_value = mw.text.split(field_value, element_delimiter)			return field_name, iter(field_value):map(convert):filter(op.truth):totable		else			return field_name, convert(field_value)		end	end):tomap end

-- Returns the output of the #cargo_store parser function according to the definition -- of this CargoTable, using the provided table `values`: { field_name -> field_value }. -- `out_query` is optional. If it is given, it will be modified such that it is the same -- as the record returned by `CargoTable:query` (selecting all fields, including metadata fields). -- Note: This silently drops attempts to store a different Cargo record that has identical -- fields as an existing one, without raising an error. function CargoTable:store(values, out_query) assert(values, "`values` cannot be nil") if type(values) ~= "table" then error(string.format("`values` is not a table, found it to be: %s", mw.dumpObject(values))) end if out_query ~= nil and type(out_query) ~= "table" then error(string.format("`out_query` must be nil or a table, but found %s", mw.dumpObject(out_query))) end -- Use frame that is highest in the hierarchy to inspect title local title = mw.title.new(mw.getCurrentFrame:preprocess("")) -- Temporarily add the values of predefined fields for constraint checking assert(values._pageName == nil, "Attempted to store a custom value for the metadata field '_pageName") values._pageName = title.fullText assert(values._pageTitle == nil, "Attempted to store a custom value for the metadata field '_pageTitle") values._pageTitle = title.text assert(values._pageNamespace == nil, "Attempted to store a custom value for the metadata field '_pageNamespace") values._pageNamespace = title.namespace assert(values._pageID == nil, "Attempted to store a custom value for the metadata field '_pageID") values._pageID = title.id	assert(values._ID == nil, "Attempted to store a custom value for the metadata field '_ID") -- Unable to get value of "_ID" local content_fields, metadata_fields = iter(self._body):partition(function (k) return iter(CargoTable.METADATA_FIELDS):index(field_name) == nil end) assert(iter(metadata_fields):is_null, "Attempted to pass in a metadata field") local content_kv = iter(content_fields):map(function (k, _) return k, values[k] end):tomap local metadata_kv = iter(metadata_fields):map(function (k, _) return k, values[k] end):tomap -- Get the values to store local args = iter(self:LuaToStringFields(content_kv)) local args_all = args:chain(self:LuaToStringFields(metadata_kv)):tomap args = args:tomap -- Store the record first -- First index is populated to work around the error related to callParserFunction args[1] = '' local store = mw.getCurrentFrame:callParserFunction(string.format("#cargo_store: _table = %s", self._name), args) -- Check primary_key local function getEqualsSubstr(field_name) field_value = assert(args_all[field_name], string.format("Primary key field '%s' cannot be null", field_name)) field_value = CargoTable:standardizeStringValue_insert(field_value) local field_type = self._body[field_name][1] local element_delimiter, element_type = CargoTable:parseElementInfo(field_type) if element_delimiter then error(string.format("Primary key field '%s' cannot be a list", field_name)) elseif field_type == "Boolean" then if field_value == "1" or field_value == "yes" then return string.format("%s=TRUE", field_name) else return string.format("%s=FALSE", field_name) end else return string.format("%s='%s'", field_name, field_value) end end local pk_where = table.concat(iter(self._primary_key):map(getEqualsSubstr):totable, " AND ") local num_pk_results = tonumber(mw.ext.cargo.query(self._name, "COUNT(*)=num_results", {where = pk_where})[1].num_results) assert(num_pk_results <= 1, string.format("Found %d records with the same primary key: %s", num_pk_results, pk_where)) -- Write converted fields to out_query if out_query then args_all = self:stringToLuaFields(args_all) for k, v in pairs(args_all) do out_query[k] = v end end return store end

-- Returns the output of the #cargo_query parser function according to the definition -- of this CargoTable, returning all fields of this CargoTable, including the metadata -- fields, given `args` to pass to the parser function. -- For fields involving a list, they are converted into Lua tables. For fields of type -- "Integer", "Float" or "Boolean", they are automatically converted into Lua equivalents. -- If `is_key` is `true` (defaults to `false`), checks whether the query returns one and only one result. function CargoTable:query(args, is_key) if not args then error("`args` cannot be nil") end if type(args) ~= type({}) then error(string.format("`args` is not a table, found it to be: %s", args)) end args = iter(args):map(function (k, v) return k, CargoTable:standardizeStringValue_insert(v) end):tomap local results = mw.ext.cargo.query(self._name, table.concat(self:getFields, ','), args) if is_key then assert(#results == 1, string.format("Found %d results but expected to find one and only one result. `args` = %s", #results, mw.dumpObject(args))) end -- Convert each value to their Lua equivalent results = iter(results):map(function (result) return self:stringToLuaFields(result) end):totable return results end

return { CargoTable = CargoTable, }