Lua example

From MWStake
Jump to navigation Jump to search

Intro

This is not a guide to Lua syntax or behavior, and it's also not documentation. What it does instead is talk about a simple example of a common use case in MediaWiki. I'd suggest reading it as maybe a 2nd or 3rd source when trying to start coding in Lua for a wiki. My example uses Cargo, but you can do very similar things with SMW (or DPL?), or with no databasing extension at all. I also recommend the book Clean Code by Robert C. Martin. The examples in that book are in Java, but even if you don't know any Java you can still take away a lot of useful things from it.

A couple things you should keep in mind about my coding style:

  • data is always a table queried from Cargo
  • processed is a table created by processing that data
  • make as the first word of a function name means "create and return an mw.HTML object"
  • print means "add sections to the mw.html object that is the function's first argument"
  • tag means "add a self-contained unit to an mw.html object" (e.g. a footnote)

There are more, but the point is a lot of these arbitrary-seeming names are in fact very deliberate, and globally consistent through my code base (or at least since I decided to start doing things like this). You should try to have internally-consistent conventions as much as possible so that someone reading your code knows what to expect. Have a reason for doing everything!

How to Invoke

For simplicity, on this page I use the invoke parser function directly, e.g. {{#invoke:CatsAdoptedTable|main}}. But usually you will want to put your invokes inside of templates so that users don't have to worry about the specific syntax - and also to add an extra layer between your call of the code and the definition of where the code is, so if you decide to reorganize things later and your function is no longer called main, it's just a single replacement to change it later. Making potential future changes easy is a constant theme in coding.

Goal

Let's adopt some cats! Check out the data at Help:List of Adopted Cats. We want to build a table based on that data that looks like this:

{|class="wikitable"
| rowspan = 3 | Sunday || Luna
|-
|Chloe
|-
|Bella
|-
| Monday || Lucy
|}
Sunday Luna
Chloe
Bella
Monday Lucy

As you can see, this is pretty easy to do but it requires that we know ahead of time how many cats were adopted each day so that we can set the correct rowspan. So what if we want to automate this data? Let's use Lua. You can check the final source at Template:Mod.

Writing Our First Lua Module

Check out Template:Mod. It contains the following code:

local util_args = require('Module:ArgsUtil')

local h = {}

local p = {}
function p.main(frame)
	local args = util_args.merge(true)
	return args.kittens
end
return p

When we run this in the main wiki, we get the following:

{{#invoke:HelloWorld|main|kittens=cute}}

Script error: No such module "HelloWorld".

Don't worry what util_args.merge(true) does. In fact, all of this is pretty much just boilerplate code other than the line return args.kittens. You can use the rest as a copy-paste basis for everything (assuming you have a module like Template:Mod that grabs the template arguments for you on your wiki).

Writing Our First Lua Module That Does Something

Cargo

First of all, just trust me that the following code will return us a Cargo table of the following form, where cat and day are the data values from the table we stored at Help:List of Adopted Cats:

{
	{ CatName = cat, Day = day },
	{ CatName = cat, Day = day },
	{ CatName = cat, Day = day },
	{ CatName = cat, Day = day },
}
function h.doQuery()
	local query = {
		tables = 'CatsAdopted',
		fields = {
			'Day',
			'CatName'
		},
		limit = 9999
	}
	return util_cargo.queryAndCast(query)
end

You can look at the Cargo documentation for how mw.ext.cargo.query works, and Template:Mod for how util_cargo.queryAndCast works, but this page is about Lua not Cargo, so don't worry about it if you don't want to; this is just a convenient way to get some data.

Main Function

So let's write our main function first. Generally we want to separate code into 3 parts:

  1. Get the data (in this case, do the Cargo query)
  2. Manipulate the data
  3. Print the data

These sections should be TOTALLY SEPARATE from each other: we don't want to get some data, then format it, then get more data, then format it, etc; nor do we want to construct our output object at the same time as we're manipulating the data. Sometimes there will be case-by-case situations where these steps blur together, but in instances of that happening, it's generally better to increase the number of distinct steps (maybe we manipulate then format then print, or maybe we get data then calculate totals then do the rest of the processing, etc) rather than decrease them.

So here's a nice clean main function:

function p.main(frame)
	local args = mw.getCurrentFrame().args
	local data = h.doQuery(args)
	local processed = h.processData(data)
	return h.makeOutput(processed)
end

Calling the main function is the only way we should ever interact with this module from elsewhere, so in fact let's go one step further and put this function as the only thing in our export table:

local p = {}
function p.main(frame)
	local args = util_args.merge(true)
	local data = h.doQuery(args)
	local processed = h.processData(data)
	return h.makeOutput(processed)
end
return p

All of the supporting functions we write will be "helper" functions or "private" functions that are only able to be accessed from within this single module, so we'll put them in a separate table called h (h for "helper") that won't be returned at the end.

Code Part 1

You can see this at Template:Mod.

local util_args = require('Module:ArgsUtil')
local util_cargo = require('Module:CargoUtil')


local h = {}

function h.doQuery()
	local query = {
		tables = 'CatsAdopted',
		fields = {
			'Day',
			'CatName'
		},
		limit = 9999
	}
	return util_cargo.queryAndCast(query)
end

function h.processData(data)
end

function h.makeOutput(processed)
end

local p = {}
function p.main(frame)
	local args = util_args.merge(true)
	local data = h.doQuery()
	local processed = h.processData(data)
	return h.makeOutput(processed)
end
return p

You can run this and it will run! It just won't return anything interesting yet.

{{#invoke:CatsAdoptedTable/Part 1|main}}

Script error: No such module "CatsAdoptedTable/Part 1".
(The line above this contains the output. Yes, it's empty. But it's not throwing any error!)

Designing A Data Structure

When you're using MediaWiki parser functions to do stuff, you have arrays that you can manipulate a bit, but it's super awkward to do that much with them. Imagine the pain of working with an array where each element itself is an array! And if you wanted to have keyed data instead of ordered data, well...you can't, so you'd need a separate object for each sub-array, with the keys stored in their own array, and.......yeah no. In Lua, though, it's super easy to set up nice data structures. I'll talk about one I like in particular:

{
	'Sunday', 'Monday', 'Tuesday',
	Sunday = {},
	Monday = {},
	Tuesday = {},
	etc.
}

Here we have an ordered list of keys Sunday, Monday, etc, and their data are also in the same table. This is nice because we can iterate over the integer keys with ipairs (pairs would iterate over both integer and non-integer keys). Now we can sort the integer keys using table.sort, and then print stuff in the order we want.

This kind of "ordered dictionary" turns out to be a useful structure to return to frequently, particularly when working with Cargo data that gets grouped together into "containers," like this cat-adoption data.

Processing Our Data

We need to take our one-cat-per-row table and turn it into a one-day-per-row table, with a sub-table that contains a list of cats.

function h.processData(data)
	local processed = {}
	for _, row in ipairs(data) do
		local day = row.Day
		local cat = row.CatName
		local dayTable = processed[day] -- dayTable is now an alias for processed[day]
		if dayTable then
			dayTable[#dayTable+1] = cat -- Lua doesn't have a method like .append, instead you literally just say "make the value at 1 more than the current length this"
		else
			processed[#processed+1] = day
			processed[day] = { cat }
		end
	end
	table.sort(processed, h.sortByWeekOrder)
	return processed
end

So what we do here is to go through each row in our data, extract the day and cat name, and then do one of two things:

  • If this is the first cat on a new day, then add that day to our table, and add the cat as the first cat in that day, or
  • If not then just add the cat to the existing day-list-of-cats.

After we're done with that we just sort the table.

Sorting The Table

We just hard code the list of days of the week at the start of the module. Depending on your wiki, you may want to have some "libraries" that just contain ordered lists to order by, like day of the week or month of the year etc. You could also in this case import some time library and use a function for that, but we'll just do it ourselves.

local DAY_ORDER = {
	'Sunday',
	'Monday',
	'Tuesday',
	'Wednesday',
	'Thursday',
	'Friday',
	'Saturday'
}

local DAY_ORDER_BETTER = {
	Sunday = 1,
	Monday = 2,
	Tuesday = 3,
	Wednesday = 4,
	Thursday = 5,
	Friday = 6,
	Saturday = 7
}

function h.sortByWeekOrder(day1, day2)
	return DAY_ORDER_BETTER[day1] < DAY_ORDER_BETTER[day2]
end

So you might notice I have two different ordering tables here. The second is just easier to work with, and in fact I have a generic library function which takes a table like the first, transforms it to a table like the second, and then sorts your array by it. But for this example I wrote out the steps here instead of using a "built-in" function.

Printing Output

So at this point in time, we haven't had to say a single thing about how we're presenting the data. We could be making a table, an ordered list, an unordered list, even a calendar - everything up until this point would still be exactly the same. This is really nice because it means if our requirements on data presentation change in the future, we can just change one small block of code instead of the entire module. Yay!

function h.makeOutput(processed)
	local tbl = mw.html.create('table')
		:addClass('wikitable')
	for _, day in ipairs(processed) do
		h.printDay(tbl, day, processed[day])
	end
	return tbl
end

We aren't doing much so far but that's okay, we'll put more specific details in h.printDay.

The next thing I wrote was actually not h.printDay but rather h.printCat.

function h.printCat(tr, cat)
	tr:tag('td'):wikitext(cat)
end

The reason I wrote this first was that there are two separate cases in h.printDay: 1) the first cat in the day; and 2) subsequent cats in the day. The latter case will require creating a new row, whereas the former just adds a cell to an already-existing row.

function h.printDay(tbl, day, catList)
	local tr = tbl:tag('tr')
	h.printDayName(tr, day, #catList)
	local cat = table.remove(catList,1)
	h.printCat(tr, cat)
	for _, thiscat in ipairs(catList) do
		local trSub = tbl:tag('tr')
		h.printCat(trSub, thiscat)
	end
end

function h.printDayName(tr, day, rowspan)
	tr:tag('td')
		:attr('rowspan', rowspan)
		:wikitext(day)
end

So here's the actual job of printing out the entire table. We add a row, print the day cell with the rowspan (which is of course the total number of cats), and then print the first cat. Because we don't need to reuse this table ever, it's convenient to actually remove that cat from the table and then we can just use ipairs over the entire remainder of the table, but it's not necessary to do it that way.

Here's an alternative way to write the same function:

function h.printDay(tbl, day, catList)
	for i, cat in ipairs(catList) do
		local tr = tbl:tag('tr')
		if i == 1 then
			h.printDayName(tr, day, #catList)
		end
		h.printCat(tr, cat)
	end
end

function h.printDayName(tr, day, rowspan)
	tr:tag('td')
		:attr('rowspan', rowspan)
		:wikitext(day)
end

You can see that we still do the same thing of treating index 1 as a special case, and the function to print the day name is exactly the same, but now we make the loop more inclusive. Convince yourself these do exactly the same.

Final Result

And that's it! Check Template:Mod for code. There are some blocks commented out based on what we talked about above.

Here's the final table:

Script error: No such module "CatsAdoptedTable".

Challenge

See if you can adjust the output functions to print a list instead of a table. You can feel free to create modules on this wiki, but they might be deleted in cleanups on occasion. Please use your user space for anything other than modules.