• 🏆 Texturing Contest #33 is OPEN! Contestants must re-texture a SD unit model found in-game (Warcraft 3 Classic), recreating the unit into a peaceful NPC version. 🔗Click here to enter!
  • It's time for the first HD Modeling Contest of 2024. Join the theme discussion for Hive's HD Modeling Contest #6! Click here to post your idea!

[Lua] Multidimensional Table

Level 20
Joined
Jul 10, 2009
Messages
477

Overview

Code (Full version)

Code (Lite version)

Code (XLite version)

Changelog

Overview

A resource to create multidimensional tables.
Lua:
T = MDTable.create(3) --Creates a table with 3 dimensions.
T[1][2][3] = "Hello World" --Directly read and write to all dimensions. No subtable creation required.

The standard way of creating multi-dimensional tables in Lua is to nest 1-dimensional subtables.
If you want to access T[key1][key2][key3], then you have to make sure that both T[key1] and T[key1][key2] hold a subtable.
Making sure that the structure works right can be a tedious process, especially if the keys you want to access change during the course of the game.
Code using multidimensional tables commonly looks like this:
Lua:
--Maintaining multi-dimensional structures can be tedious:
function setValue(key1,key2,key3,value)
    T[key1] = T[key1] or {}
    T[key1][key2] = T[key1][key2] or {}
    T[key1][key2][key3] = value
end

function getValue(key1,key2,key3)
    if T[key1] and T[key1][key2] then
        return T[key1][key2][key3]
    end
    return nil
end

You can save this effort by using MDTables instead, which don't require subtable management.

Installation

Required: Paste the code from either of the Code tabs into your map.

Optional: If you need a multiplayer-synchronous pairs-loop over MDTables, import SyncedTable to your map.

Multidimensional Table

This resource provides the option to create multidimensional tables (short: MDTables). MDTables allow for direct access of all table dimensions without subtable preparation.
MDTables also allow to specify default values and provide tools for convenient looping (multiplayer-synced).

Main Use

Lua:
-- MDTable.create(numDimensions)
-- creates a table that allows for direct access of the specified amount of dimensions.

T = MDTable.create(3) --creates a multidimensional table with 3 dimensions
T[1][2][Player(0)] = 42
print(T[1][2][Player(0)]) --prints "42"
print(T[1][2][3]) --prints "nil", because no value has been assigned to the specified set of keys
print(T[1][2][3][4]) --throws an error, because the MDTable has been created with 3 dimensions only.

Loops

MDTables provide a custom pairs-iteration that allows to access all keys from the different dimensions at once.
This feature is not included in the Lite version.

Lua:
-- for key_1, key_2, ..., key_n, value in pairs(T) do [doStuff] end

T = MDTable.create(3)
T[1]["bla"][1.5] = 42
for key1, key2, key3, value in pairs(T) do
    print(key1, key2, key3, value) --prints "1   bla   1.5   42"
end

Default Values

The create-method allows to specify a default value to be returned instead of nil-values.

Lua:
-- MDTable.create(numDimensions, defaultValue)

T = MDTable.create(3, "Hello")
print(T[1][2][3]) --prints "Hello". T[1][2][3] will still not contain a value (value isn't persisted).

for key1, key2, key3, value in pairs(T) do
    print(key1, key2, key3, value) -- doesn't print anything, because T is still empty.
end

Function-type default values

If you specify a function as default value, that function will be executed (taking all keys accessed) and its return value will be used.

Lua:
local sumOfKeys = function(key1,key2,key3)
    return key1 + key2 + key3
end

T = MDTable.create(3, sumOfKeys)
print(T[1][2][3]) --prints "6"
print(T[1]["bla"][5]) --throws an error, because the specified function wasn't able to deal with the string parameter.

Persistent default values

The create-method also allows to specify, whether a default value should be permanently saved into the table upon read access. By default, that is not the case.

Lua:
-- MDTable.create(numDimensions, defaultValue, persistDefault_yn)

T = MDTable.create(3, "Hello", true) --setting third parameter to true will permanently save default in the table upon read access
print(T[1][2][3]) --prints "Hello". T[1][2][3] now contains "Hello"

for key1, key2, key3, value in pairs(T) do
    print(key1, key2, key3, value) -- prints "1   2   3   Hello"
end

Prebuilding Data Structures

The combination of function evaluations and persistent default values can be very powerful, when it comes to prebuilding data structures.
Imagine you want to create a 2-dimensional table T[player][levelId], which holds a table counting kills and received Gold for each player in any level of a tower defense.
You could do that by simply writing the following:
Lua:
function createDatatable(player, levelId)
    return {kills = 0, receivedGold = 0, playerName = GetPlayerName(player)}
end

T = MDTable.create(2, createDatatable, true) --2-dimensional table with function-type persisting default

--Imagine we are in round 1 and want to increase the kill count for Player(0) by 1.
T[Player(0)][1].kills = T[Player(0)][1].kills + 1 --works. Table access created the default value, persisted it and immediately allows for manipulating properties.

for player, levelId, dataTable in pairs(T) do
    print(dataTable.kills, dataTable.playerName) --prints "1" and the name of Player(0).
end

Multiplayer-syncronous pairs-loop

By default, the pairs-iteration is not synchronous between players in a multiplayer-game. This fact can lead to desyncs, if you change game state inside such a loop.
If you plan to iterate over an MDTable in a multiplayer-game, you can import SyncedTable to your map and use the fourth parameter of the MDTable-create-method to make the pairs-iteration synchronous, thus preventing desyncs.
Lua:
-- MDTable.create(numDimensions, defaultValue, persistDefault_yn, syncedLoop_yn)
-- setting syncedLoop_yn to true makes the pairs-iteration synchronous in a multiplayer-game. This setting requires the SyncedTable-library.

T = MDTable.create(3, "Hello", false, true)
T[1][2][3] = "someValue"
T["bla"][1.5][false] = "whatever"

--Synced Loop
for key1, key2, key3, value in pairs(T) do
    [you can change gamestate here]
end
Bear in mind that all those optional parameters can be skipped, but the order is still important (and syncedLoop_yn is always the fourth one). So if you want to create a 3-dimensional multiplayer-synced MDTable without default value, you would need to call T = MDTable.create(3, nil, nil, true). If you find this inconvenient, just build your own wrapper around it ;)

NestedSet & NestedGet

The resource also comes with the following two functions that simplify accessing multiple dimensions for any table.

Lua:
-- table.nestedSet(t, ... , value)
-- Saves the specified value at the specified keys within t. Creates subtables, if necessary.

--Example
t = {}
--We want to set t[1][2][3][4] = "Hello",
table.nestedSet(t, 1, 2, 3, 4, "Hello") --automatically creates subtables in t[1] and t[1][2] etc., if not already present.

Lua:
-- table.nestedGet(t, ...)
-- Returns the value that t has stored at the specified keys. Returns nil, if any subtable is nil.

--Example
t = {}
table.nestedSet(t, 1, 2, 3, 4, "Hello") -- t[1][2][3][4] = "Hello"
print(table.nestedGet(t, 1, 2, 3, 4)) --prints "Hello" (the value at t[1][2][3][4])
print(table.nestedGet(t, 5, 6)) --prints "nil", because t[5] doesn't hold a subtable.

Lua:
--Restrictions

-- table.nestedSet will throw an error, if the specified key-path contains non-table values:
t["error"] = 10 -- t["error"] already stores a value, so table.nestedSet throws an error.
table.nestedSet(t, "error", "cantNestFurther", 20) --error

-- The same holds for table.nestedGet
table.nestedGet(t, "error", "cantNestFurther") --error

nestedSet and nestedGet can be used on any table, so they have been incorporated into the table-library.

Features:

Read and write to multiple table dimensions✅
Default values✅
Multidimensional Looping✅
table.NestedSet and table.NestedGet✅

Lua:
if Debug and Debug.beginFile then Debug.beginFile("MDTable") end
--[[-----------------------------------------------------------------------------------------------------------------------------------------------
*   -------------------------------
*   | Multidimensional Table v1.1 |
*   -------------------------------
*
*    by Eikonium
*        --> https://www.hiveworkshop.com/threads/multidimensional-table.339498/
*
*        - Multidimensional tables, short MDTables, are tables that allow for direct access of multiple table dimensions without the need of manual subtable creation.
*        - MDTables also provide the option of choosing a default value to be returned upon accessing non-existing keys. That default value is allowed to depend on the keys accessed.
*        - Finally, MDTables come with a custom pairs-iteration, which grants access to all combinations of keys that hold a value. See below for further details.
*
*    MDTable.create(numDimensions: integer) --> table
*        - Standard use:
*          Creates an MDTable with the specified number of dimensions. E.g. "T = MDTable.create(3)" will allow you to read from and write to T[key1][key2][key3] for any arbitrary set of 3 keys.
*          You can think of it like a tree with constant depth that only allows you to write into the "leaf level" (using exactly the number of keys as dimensions specified).
*        - In the example with 3 dimensions, you should only write to T[key1][key2][key3], never to T[key1] or T[key1][key2].
*          Reading is fine on every level, but you are probably not interested in the subtable stored in T[key1].
*          You can however manually save further tables in T[key1][key2][key3] (at leaf level).
*        - MDTables will automatically create subtables on demand, i.e. upon reading from or writing to a combination of keys.
*    MDTable.create(numDimensions: integer, defaultValue: function|any) --> table
*        - Default Values:
*          Reading a nil value from the MDTable will instead return the specified defaultValue (which is nil, if not specified).
*        - Function-valued default value:
*          If defaultValue is a function, that function's return value will be returned. The function will be called with all requested keys as its arguments.
*          E.g. if T is an MDTable with 3 dimensions and defaultValue is a function, accessing an empty slot T[key1][key2][key3] will return defaultValue(key1,key2,key3).
*    MDTable.create(numDimensions: integer, defaultValue: function|any, persistDefault_yn: boolean) --> table
*        - Persisting default values:
*          When MDTables return the default values, they normally don't save it permanently.
*          You can make them do so by setting persistDefault_yn to true.
*        - Example: Let T be an MDTable with 3 dimensions, defaultValue = function(...) return {...} end, persistDefault_yn = true.
*          Now reading a nil-value at T[key1][key2][key3] will permanently save {key1,key2,key3} in that table slot.
*    MDTable.create(numDimensions: integer, defaultValue: function|any, persistDefault_yn: boolean, syncedLoop_yn: boolean) --> table
*        - Synced Loops:
*          Setting the syncedLoop_yn-parameter to true will make the iteration synchronous in multiplayer games (at the cost of some overhead), which prevents desyncs.
*          Hence it should be set to true, if and only if you plan to iterate over it in a multiplayer game. See comments on iteration below.
*          This setting requires the SyncedTable-library (https://www.hiveworkshop.com/threads/syncedtable.332894/).
*
*    for key1, key2, ..., key_n, value in pairs(<MDTable>) do [doStuff] end
*        - You can iterate over an MDTable by using Lua's native pairs(). Loop-parameters are all keys plus the value at leaf level.
*        - Example:
*           local T = MDTable.create(3, 42) --creates an MDTable with 3 dimensions and default value 42.
*           T[1][2][3] = "Hello"
*           T[1]["bla"][Player(0)] = "World"
*           print(T[2][3][4]) --> prints the defaultValue "42", but doesn't add that value to the table. Hence, it will not be iterated below.
*           for key1, key2, key3, value in pairs(T) do
*               print(key1, key2, key3, value) --will print    "1    2    3    Hello"    and    "1   bla   Player(0)   World"
*           end
*        - As all subtables of MDTables up to leaf level are also MDTables, you can iterate over subtables with the same method. Note that subtables have a lower dimension.
*           --Following the example from above
*           for key1, key2, value in pairs(T[1]) do
*               print(key1, key2, value) --> will print "2   3   Hello"
*           end
*
*   -------------------------
*   | nestedSet & nestedGet |
*   -------------------------
*
*   table.nestedSet(t, ..., value)
*        - Saves the specified value at the specified keys within t. Creates subtables, if necessary.
*        - E.g. nestedSet(t, 1, 2, 3) is equivalent to t[1][2] = 3 and will save a subtable in t[1], if not already present.
*   table.nestedGet(t, ...)
*        - Returns the value that t has stored at the specified keys. Returns nil, if any of subtable is nil.
*        - E.g. nestedGet(t, 1, 2) returns t[1][2] (or nil, if t[1] is nil).
-------------------------------------------------------------------------------------------------------------------------------------------------]]
do
    local nestedGet
    nestedGet = function(t, k, ...)
        if k == nil and select('#', ...) == 0 then
            return t
        elseif t[k] == nil then
            return nil
        end
        return nestedGet(t[k], ...)
    end
    ---Multidimensional get-operation.
    ---
    ---Returns the value from the specified table at the specified set of keys.
    ---E.g. table.nestedGet(t, 1,2,3) will return t[1][2][3].
    ---If any of the subtables in the path are nil, table.nestedGet will return nil.
    ---Using this function frees you from checking every dimension for whether it contains a subtable or not.
    ---@param t table
    ---@param ... any keys to access from t
    ---@return any value
    table.nestedGet = function(t, ...) return nestedGet(t,...) end

    local nestedSet
    nestedSet = function(t, k, v, ...)
        if select('#', ...) == 0 then
            t[k] = v
        else
            if t[k] == nil then --don't use or-operator. Might overwrite false-key.
                t[k] = {}
            end
            nestedSet(t[k], v, ...)
        end
    end
    ---Multidimensional set-operation.
    ---
    ---Sets the table at the specified set of keys to the specified value. Creates subtables, where necessary.
    ---E.g. table.nestedSet(t, 1, 2, 3, 42) will set t[1][2][3] = 42 and create subtables at t[1] and t[1][2], if not already present.
    ---Using this function frees you from checking for existing subtables.
    ---@param t table
    ---@param ... any keys to access and value to set. The last parameter will be taken as the value.
    table.nestedSet = function(t, ...) nestedSet(t, ...) end

    MDTable = {}

    local unpack = table.unpack

    --Prepare table holding meta-information about every Multidimensional table and its subtables.
    --Every subtable has a depth - the base table starts at 1 and the value increases for 1 for each nested level.
    --The number of dimensions is referred to as maxDepth. It equals the depth at which the actual values are stored (instead of further subtables).
    --The user can choose a default value to be returned, when no value has been saved in a certain combination of keys before. Returning a default value will persist it in the table, if specified in the create-method.
    --SyncedTables also need to save their old __index and __pairs metamethods to allow the new methods to call those.
    local metaInfo = {} ---@type table<table,{depth:integer,maxDepth:integer,default:any,persistDefault:boolean,oldIndex:function,oldPairs:function}>

    ----------------------------------------
    --| Implementation for normal tables |--
    ----------------------------------------

    -- MDTables have a different implementation for normal tables and SyncedTables. Normal tables use the class itself as their metatable. SyncedTables manipulate the metatable they already have.

    --The index-metamethod creates new subtables or returns the default-value, depending on which nested level the read access has been conducted.
    --As the same metatable is applied to all subtables, the parameter t can be any subtable on any level.
    MDTable.__index = function(t, key)
        local tMetaInfo = metaInfo[t]
        if tMetaInfo.depth == tMetaInfo.maxDepth then
            local default = tMetaInfo.default
            tMetaInfo[tMetaInfo.depth] = key --add current accessed key to the table to add it to the unpacking. This will be overwritten on every call, but doesn't matter.
            local defaultVal = (type(default) == 'function' and default(unpack(tMetaInfo, 1, tMetaInfo.depth))) or default --return default-value for full level read access
            if tMetaInfo.persistDefault then
                t[key] = defaultVal
            end
            return defaultVal
        else
            local newDim = {} --create new subtable on read access below full level
            t[key] = newDim --save subtable to requested key
            --prepare meta info for newDim
            local newMetaInfo = {
                default = tMetaInfo.default         --pass default value from t to its new subtable
                ,   depth = tMetaInfo.depth + 1     --depth of subtable is 1 higher than depth of t (obviously)
                ,   maxDepth = tMetaInfo.maxDepth   --pass max depth from t to subtable
                ,   persistDefault = tMetaInfo.persistDefault --pass information on whether to persist default value to subtable
            }
            --copy path of t to subtable
            for i = 1, tMetaInfo.depth - 1 do
                newMetaInfo[i] = tMetaInfo[i]
            end
            newMetaInfo[tMetaInfo.depth] = key --last key of the "path" to the new subtable
            metaInfo[newDim] = newMetaInfo
            setmetatable(newDim, MDTable) --apply same metatable to subtable
            return newDim --return new subtable, so the read access can continue as normal (and probably involves further nested calls of __index)
        end
    end

    --The pairs-metamethod is supposed to work for any number of dimensions, so we need recursion to get to a full level set of keys holding a value on max level.
    local loopRecursion

    ---Recursive loop for Nested Tables used by the iterator function for Multidimensinal Table.
    ---The loop reads all combinations of keys that hold a value on max level, returns that combination to the iterator and pauses the loop using coroutines, until the iterator resumes it.
    ---@param t table t can be any subtable, it's a recursion after all
    ---@param syncedLoop_yn boolean true, if loop is conducted on a multidimensional SyncedTable
    ---@param returnVals table holds all keys that the recursion has already gone through, so that they can be passed via table.unpack on demand
    ---@param loopLevel integer dimension at which the recursion is currently operating. Equals the subtable depth in case the pairs-function was used on the base table.
    loopRecursion = function(t, syncedLoop_yn, returnVals, loopLevel)
        local tMetaInfo = metaInfo[t]
        local iter = syncedLoop_yn and tMetaInfo.oldPairs() or next --iterator to be used depends on whether we deal with a normal table or a SyncedTable.
        local k, v = iter(t) --first key-value-pair to loop through at the current loop level
        --if t sits below max level, we loop through all (key,subtable)-pairs of t.
        if tMetaInfo.depth < tMetaInfo.maxDepth then
            while k ~= nil do --"while k do" won't do it - k can be "false".
                returnVals[loopLevel] = k --remember the key in the returnVals table
                loopRecursion(v, syncedLoop_yn, returnVals, loopLevel + 1) --every subtable must conduct a full loop itself
                k, v = iter(t, k) --get next key-subtable-pair
            end
        else
        --if t sits at max level, we loop through all (key,value)-pairs of t.
            while k ~= nil do
                returnVals[loopLevel] = k --remember the key in the returnVals table
                returnVals[loopLevel+1] = v --at max level, also remember the value
                coroutine.yield(unpack(returnVals, 1, loopLevel+1)) --return the complete set of keys plus final value to the iterator and pause the loop until further request
                k, v = iter(t, k) --get next key-value-pair
            end
        end
        --when the first call of the recursive loop ends, all inner recursion has already finished -> fo a final yield on the coroutine to put it to dead state
        if loopLevel == 1 then
            coroutine.yield()
        end
    end

    --Define behaviour of the pairs-function for Multidimensional tables based on normal tables.
    --The pairs-function is supposed to loop through all combinations of keys that hold a value at max level.
    --It enables the following syntax: for key1, key2, ..., key_n, value in pairs(T) do ... end
    MDTable.__pairs = function(t)
        local cor = coroutine.create(loopRecursion) --we need a coroutine to pause the loop recursion between every request of the iterator function
        local returnVals = {} --table to hold all return values for this specific loop. Passed to the recursion.
        return function()
            --successful coroutine calls return true plus the yielded values, so we need to return everything after the first value
            return select(2,coroutine.resume(cor, t, false, returnVals, 1))
        end, t, nil
    end

    --Allows to call the Multidimensional table directly via T(key1,key2,...,key_n)
    MDTable.__call = function(t, ...)
        return table.nestedGet(t, ...)
    end

    ---------------------------------------
    --| Implementation for SyncedTables |--
    ---------------------------------------

    --SyncedTables need special treatment, as they all have their own individual metatable with __index and __pairs already defined.
    --We basically define new index and pairs for the SyncedTable by using the old instance.
    local prepareSyncedTable

    local syncedIndex = function(t, key)
        local tMetaInfo = metaInfo[t]
        local oldIndexVal = tMetaInfo.oldIndex(t, key)
        --if the SyncedTable holds a value for the given key, return that value
        if oldIndexVal then
            return oldIndexVal
        --return default value, if there is no stored value and the user is reading at max level
        elseif tMetaInfo.depth >= tMetaInfo.maxDepth then
            local default = tMetaInfo.default
            tMetaInfo[tMetaInfo.depth] = key --add current accessed key to tMetaInfo to add it to the unpacking below. The key at full depth will be overwritten on every call, but doesn't matter.
            local defaultVal = (type(default) == 'function' and default(unpack(tMetaInfo, 1, tMetaInfo.depth))) or default
            if tMetaInfo.persistDefault then
                t[key] = defaultVal --triggers SyncedTable newIndex metamethod
            end
            return defaultVal
        --create sub-SyncedTable, if not already present and reading below max level
        else
            local newDim = SyncedTable.create()
            t[key] = newDim --don't use rawset. This must trigger SyncedTable __newindex to work.
            local newMetaInfo = {
                default = tMetaInfo.default
                ,   depth = tMetaInfo.depth + 1
                ,   maxDepth = tMetaInfo.maxDepth
                ,   persistDefault = tMetaInfo.persistDefault
            }
            metaInfo[newDim] = newMetaInfo
            prepareSyncedTable(newDim) --again manipulate metatables of new SyncedTable
            --copy path from tMetaInfo to newMetaInfo
            for i = 1, tMetaInfo.depth - 1 do
                newMetaInfo[i] = tMetaInfo[i]
            end
            newMetaInfo[tMetaInfo.depth] = key
            return newDim
        end
    end

    --Define multidimensional pairs-function. Uses the same loopRecursion as for MDTables based on normal tables.
    local syncedPairs = function(t)
        local cor = coroutine.create(loopRecursion)
        local returnVals = {}
        return function()
            --successful coroutine calls return true plus the yielded values, so we need to return everything after the first value
            return select(2,coroutine.resume(cor, t, true, returnVals, 1))
        end, t, nil
    end

    ---Manipulates the metatable of a SyncedTable in a way that it allows for nested access.
    ---Writes into metaInfo[t], so requires that table to already exist.
    ---@param t table actually SyncedTable, but we don't mention the true type to prevent "undefined type" errors, when SyncedTables is not being used (optional dependency).
    prepareSyncedTable = function(t)
        local mt = getmetatable(t) --original metatable of the SyncedTable to be manipulated
        local tMetaInfo = metaInfo[t]
        tMetaInfo.oldIndex = mt.__index --original __index
        tMetaInfo.oldPairs = mt.__pairs --original __pairs-function must be saved somewhere to be accessible from within the loop recursion
        --SyncedTable __index triggers on every read action, because the table itself is kept empty by design. That means we must also deal with the case where the SyncedTable actually holds a value for the requested key.
        mt.__index = syncedIndex
        mt.__pairs = syncedPairs
        mt.__call = MDTable.__call
    end

    ---Create a new Multidimensional Table.
    ---@param numDimensions integer number of dimensions accessible on the new Multidim Table
    ---@param defaultValue? function|any default: nil. Default value to return at leaf level access, when the requested combination of keys has no value assigned. If function: calls that function and uses its return value.
    ---@param persistDefault_yn? boolean default: false. set to true to persist the default value in the table at the requested combination of keys upon read access, when there wasn't yet any value assigned.
    ---@param syncedLoop_yn? boolean default: false. set to true to allow for multiplayer-synced pairs-loop. Requires SyncedTable. Set to false, if you create a singleplayer game or if you don't plan to loop over this table.
    ---@return table
    MDTable.create = function(numDimensions, defaultValue, persistDefault_yn, syncedLoop_yn)
        local newTable
        --If user desires synced loop and the SyncedTable-library is present, create SyncedTable and manipulate its metatables.
        if syncedLoop_yn and SyncedTable then
            newTable = SyncedTable.create()
            metaInfo[newTable] = {}
            prepareSyncedTable(newTable) --manipulating existing metatable instead of assigning the class as metatable.
        else
        --If user desires normal loop or no loop, create normal table and set standard metatable.
            newTable = {}
            metaInfo[newTable] = {}
            setmetatable(newTable, MDTable)
        end
        metaInfo[newTable].depth = 1  --the base table has depth 1
        metaInfo[newTable].maxDepth = numDimensions --maxdepth is the dimension chosen by the user
        metaInfo[newTable].default = defaultValue --default value for max level access
        metaInfo[newTable].persistDefault = persistDefault_yn --tells, if the default value shall be saved upon read access
        return newTable
    end
end
if Debug and Debug.endFile then Debug.endFile() end
Features:

Read and write to multiple table dimensions✅
Default values✅
Multidimensional Looping❌
table.NestedSet and table.NestedGet✅

The code is much shorter than with the full version.

Lua:
if Debug and Debug.beginFile then Debug.beginFile("MDTable") end
--[[-----------------------------------------------------------------------------------------------------------------------------------------------
*   --------------------------------------
*   | Multidimensional Table (Lite) v1.1 |
*   --------------------------------------
*
*    by Eikonium
*        --> https://www.hiveworkshop.com/threads/multidimensional-table.339498/
*
*        - Multidimensional tables, short MDTables, are tables that allow for direct access of multiple table dimensions without the need of manual subtable creation.
*        - MDTables also provide the option of choosing a default value to be returned upon accessing non-existing keys. That default value is allowed to depend on the keys accessed.
*        - The Lite-version doesn't enable a multidimensional pairs loop, but it offers much shorter code.
*
*    MDTable.create(numDimensions: integer) --> table
*        - Standard use:
*          Creates an MDTable with the specified number of dimensions. E.g. "T = MDTable.create(3)" will allow you to read from and write to T[key1][key2][key3] for any arbitrary set of 3 keys.
*          You can think of it like a tree with constant depth that only allows you to write into the "leaf level" (using exactly the number of keys as dimensions specified).
*        - In the example with 3 dimensions, you should only write to T[key1][key2][key3], never to T[key1] or T[key1][key2].
*          Reading is fine on every level, but you are probably not interested in the subtable stored in T[key1].
*          You can however manually save further tables in T[key1][key2][key3] (at leaf level).
*        - MDTables will automatically create subtables on demand, i.e. upon reading from or writing to a combination of keys.
*    MDTable.create(numDimensions: integer, defaultValue: function|any) --> table
*        - Default Values:
*          Reading a nil value from the MDTable will instead return the specified defaultValue (which is nil, if not specified).
*        - Function-valued default value:
*          If defaultValue is a function, that function's return value will be returned. The function will be called with all requested keys as its arguments.
*          E.g. if T is an MDTable with 3 dimensions and defaultValue is a function, accessing an empty slot T[key1][key2][key3] will return defaultValue(key1,key2,key3).
*    MDTable.create(numDimensions: integer, defaultValue: function|any, persistDefault_yn: boolean) --> table
*        - Persisting default values:
*          When MDTables return the default values, they normally don't save it permanently.
*          You can make them do so by setting persistDefault_yn to true.
*        - Example: Let T be an MDTable with 3 dimensions, defaultValue = function(...) return {...} end, persistDefault_yn = true.
*          Now reading a nil-value at T[key1][key2][key3] will permanently save {key1,key2,key3} in that table slot.
*
*   -------------------------
*   | nestedSet & nestedGet |
*   -------------------------
*
*   table.nestedSet(t, ..., value)
*        - Saves the specified value at the specified keys within t. Creates subtables, if necessary.
*        - E.g. nestedSet(t, 1, 2, 3) is equivalent to t[1][2] = 3 and will save a subtable in t[1], if not already present.
*   table.nestedGet(t, ...)
*        - Returns the value that t has stored at the specified keys. Returns nil, if any of subtable is nil.
*        - E.g. nestedGet(t, 1, 2) returns t[1][2] (or nil, if t[1] is nil).
-------------------------------------------------------------------------------------------------------------------------------------------------]]

do
    ---------------
    --| MDTable |--
    ---------------

    MDTable = {}
    MDTable.__name = 'MDTable'

    local unpack = table.unpack

    --Prepare table holding meta-information about every Multidimensional table and its subtables.
    --Every subtable has a depth - the base table starts at 1 and the value increases for 1 for each nested level.
    --The number of dimensions is referred to as maxDepth. It equals the depth at which the actual values are stored (instead of further subtables).
    --The user can choose a default value to be returned, when no value has been saved in a certain combination of keys before. Returning a default value will persist it in the table, if specified in the create-method.
    local metaInfo = {} ---@type table<table,{depth:integer,maxDepth:integer,default:any,persistDefault:boolean,oldIndex:function,oldPairs:function}>

    --The index-metamethod creates new subtables or returns the default-value, depending on which nested level the read access has been conducted.
    --As the same metatable is applied to all subtables, the parameter t can be any subtable on any level.
    MDTable.__index = function(t, key)
        local tMetaInfo = metaInfo[t]
        if tMetaInfo.depth == tMetaInfo.maxDepth then
            local default = tMetaInfo.default
            tMetaInfo[tMetaInfo.depth] = key --add current accessed key to the table to add it to the unpacking. This will be overwritten on every call, but doesn't matter.
            local defaultVal = (type(default) == 'function' and default(unpack(tMetaInfo, 1, tMetaInfo.depth))) or default --return default-value for full level read access
            if tMetaInfo.persistDefault then
                t[key] = defaultVal
            end
            return defaultVal
        else
            local newDim = {} --create new subtable on read access below full level
            t[key] = newDim --save subtable to requested key
            --prepare meta info for newDim
            local newMetaInfo = {
                default = tMetaInfo.default         --pass default value from t to its new subtable
                ,   depth = tMetaInfo.depth + 1     --depth of subtable is 1 higher than depth of t (obviously)
                ,   maxDepth = tMetaInfo.maxDepth   --pass max depth from t to subtable
                ,   persistDefault = tMetaInfo.persistDefault --pass information on whether to persist default value to subtable
            }
            --copy path of t to subtable
            for i = 1, tMetaInfo.depth - 1 do
                newMetaInfo[i] = tMetaInfo[i]
            end
            newMetaInfo[tMetaInfo.depth] = key --last key of the "path" to the new subtable
            metaInfo[newDim] = newMetaInfo
            setmetatable(newDim, MDTable) --apply same metatable to subtable
            return newDim --return new subtable, so the read access can continue as normal (and probably involves further nested calls of __index)
        end
    end

    --Allows to call the Multidimensional table directly via T(key1,key2,...,key_n)
    MDTable.__call = function(t, ...)
        return table.nestedGet(t, ...)
    end

    ---Create a new Multidimensional Table.
    ---@param numDimensions integer number of dimensions accessible on the new Multidim Table
    ---@param defaultValue? function|any default: nil. Default value to return instead of nil-values. If function: calls that function and uses its return value.
    ---@param persistDefault_yn? boolean default: false. Set to true to persist any returned default value in the table after read access.
    ---@return table
    MDTable.create = function(numDimensions, defaultValue, persistDefault_yn)
        local newTable = {}
        setmetatable(newTable, MDTable)
        metaInfo[newTable] = {
            depth = 1 --the base table has depth 1
            ,   maxDepth = numDimensions --maxdepth is the dimension chosen by the user
            ,   default = defaultValue --default value for max level access
            ,   persistDefault = persistDefault_yn --tells, if the default value shall be saved upon read access
        }
        return newTable
    end

    -------------------------------
    --| NestedSet and NestedGet |--
    -------------------------------

    local nestedGet
    nestedGet = function(t, k, ...)
        if k == nil and select('#', ...) == 0 then
            return t
        elseif t[k] == nil then
            return nil
        end
        return nestedGet(t[k], ...)
    end
    ---Multidimensional get-operation.
    ---
    ---Returns the value from the specified table at the specified set of keys.
    ---E.g. table.nestedGet(t, 1,2,3) will return t[1][2][3].
    ---If any of the subtables in the path are nil, table.nestedGet will return nil.
    ---Using this function frees you from checking every dimension for whether it contains a subtable or not.
    ---@param t table
    ---@param ... any keys to access from t
    ---@return any value
    table.nestedGet = function(t, ...) return nestedGet(t,...) end

    local nestedSet
    nestedSet = function(t, k, v, ...)
        if select('#', ...) == 0 then
            t[k] = v
        else
            if t[k] == nil then --don't use or-operator. Might overwrite false-key.
                t[k] = {}
            end
            nestedSet(t[k], v, ...)
        end
    end
    ---Multidimensional set-operation.
    ---
    ---Sets the table at the specified set of keys to the specified value. Creates subtables, where necessary.
    ---E.g. table.nestedSet(t, 1, 2, 3, 42) will set t[1][2][3] = 42 and create subtables at t[1] and t[1][2], if not already present.
    ---Using this function frees you from checking for existing subtables.
    ---@param t table
    ---@param ... any keys to access and value to set. The last parameter will be taken as the value.
    table.nestedSet = function(t, ...) nestedSet(t, ...) end
end
if Debug and Debug.endFile then Debug.endFile() end
Features:

Read and write to multiple table dimensions✅
Default values❌
Multidimensional Looping❌
table.NestedSet and table.NestedGet❌

Thanks to @Bribe for contributing this ultra-short version.
Note that with this version MDTables are created via table.createMD(numDimensions) instead of MDTable.create(numDimensions).

Lua:
if Debug and Debug.beginFile then Debug.beginFile("MDTable") end
--[[-----------------------------------------------------------------------------------------------------------------------------------------------
*   ----------------------------------------
*   | Multidimensional Table (X-Lite) v1.1 |
*   ----------------------------------------
*
*    Creator: Eikonium
*    Contributors: Bribe
*        --> https://www.hiveworkshop.com/threads/multidimensional-table.339498/
*
*        - Multidimensional tables are tables that allow for direct access of multiple table dimensions without the need of manual subtable creation.
*        - This X-Lite-version only offers the ability to create multi-dimensional tables, without any additional API perks of the standard MDTable or even MDTable Lite.
*
*    table.createMD(numDimensions: integer) --> table
*          Creates an MDTable with the specified number of dimensions. E.g. "T = table.createMD(3)" will allow you to read from and write to T[key1][key2][key3] for any arbitrary set of 3 keys.
*          You can think of it like a tree with constant depth that only allows you to write into the "leaf level" (using exactly the number of keys as dimensions specified).
*        - In the example with 3 dimensions, you should only write to T[key1][key2][key3], never to T[key1] or T[key1][key2].
*          Reading is fine on every level, but you are probably not interested in the subtable stored in T[key1].
*          You can however manually save further tables in T[key1][key2][key3] (at leaf level).
*        - MDTables will automatically create subtables on demand, i.e. upon reading from or writing to a combination of keys.
-------------------------------------------------------------------------------------------------------------------------------------------------]]

do
    local dimensionStorage = setmetatable({}, {__mode = 'k'})

    local repeater = {__index = function(self, key)
        local new = dimensionStorage[self] > 2 and table.createMD(dimensionStorage[self] - 1) or {}
        rawset(self, key, new)
        return new
    end}

    ---Create a new Multidimensional Table.
    ---@param numDimensions integer
    function table.createMD(numDimensions)
        local newTable = {}
        dimensionStorage[newTable] = numDimensions
        return setmetatable(newTable, repeater)
    end
end
if Debug and Debug.endFile then Debug.endFile() end

08.05.2022, v1.0:
  • Initial release
22.01.2023, v1.1:
  • Added Lite version for those who don't need a custom pairs-iteration.
  • Added Debug.beginFile and Debug.endFile statements for local file recognition with Debug Utils.
  • Improved performance of table.nestedSet and table.nestedGet. Those function no longer create a temporary table with every call.
  • Improved documentation of both code and resource main page.
31.10.2023:
  • Added @Bribe 's XLite version.
    The other versions remain unchanged, hence this is still v1.1.
 
Last edited:
Level 20
Joined
Jul 10, 2009
Messages
477
Update to v1.1
  • Added Lite version for those who don't need a custom pairs-iteration.
  • Added Debug.beginFile and Debug.endFile statements for local file recognition with Debug Utils.
  • Improved performance of table.nestedSet and table.nestedGet. Those function no longer create a temporary table with every call.
  • Improved documentation of both code and resource main page.
 

Bribe

Code Moderator
Level 50
Joined
Sep 26, 2009
Messages
9,464
Here is an X-Lite version of this resource, useful for JUST creating a multi-dimensional table without any bells and whistles:

Lua:
if Debug and Debug.beginFile then Debug.beginFile("MDTable") end
--[[-----------------------------------------------------------------------------------------------------------------------------------------------
*   ----------------------------------------
*   | Multidimensional Table (X-Lite) v1.0 |
*   ----------------------------------------
*
*    Creator: Eikonium
*    Contributors: Bribe
*        --> https://www.hiveworkshop.com/threads/multidimensional-table.339498/
*
*        - Multidimensional tables are tables that allow for direct access of multiple table dimensions without the need of manual subtable creation.
*        - This X-Lite-version only offers the ability to create multi-dimensional tables, without any additional API perks of the standard MDTable or even MDTable Lite.
*
*    table.createMD(numDimensions: integer) --> table
*          Creates an MDTable with the specified number of dimensions. E.g. "T = table.createMD(3)" will allow you to read from and write to T[key1][key2][key3] for any arbitrary set of 3 keys.
*          You can think of it like a tree with constant depth that only allows you to write into the "leaf level" (using exactly the number of keys as dimensions specified).
*        - In the example with 3 dimensions, you should only write to T[key1][key2][key3], never to T[key1] or T[key1][key2].
*          Reading is fine on every level, but you are probably not interested in the subtable stored in T[key1].
*          You can however manually save further tables in T[key1][key2][key3] (at leaf level).
*        - MDTables will automatically create subtables on demand, i.e. upon reading from or writing to a combination of keys.
-------------------------------------------------------------------------------------------------------------------------------------------------]]

do
    local dimensionStorage = setmetatable({}, {__mode = 'k'})

    local repeater = {__index = function(self, key)
        local new = dimensionStorage[self] > 2 and table.createMD(dimensionStorage[self] - 1) or {}
        rawset(self, key, new)
        return new
    end}

    ---Create a new Multidimensional Table.
    ---@param numDimensions integer
    function table.createMD(numDimensions)
        local newTable = {}
        dimensionStorage[newTable] = numDimensions
        return setmetatable(newTable, repeater)
    end
end
if Debug and Debug.endFile then Debug.endFile() end
 
Last edited:
Top