Advertisement
CluelessDev

Reactive lua table

Sep 7th, 2023 (edited)
865
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Lua 9.55 KB | None | 0 0
  1. --[[
  2.     "Reactive" luau table that allows for callbacks to be invoked on Inserted/Removed, the reasoning behind this is to facilitate
  3.     the implementation of existential processing as described in the Data Oriented Design Book https://www.dataorienteddesign.com/dodbook/node4.html
  4.    
  5.     Given that in theory it would allow devs to call functions/fire events on the table being inserted removed w/o
  6.     having to make whole function libraries or managers.
  7.    
  8.    
  9.     Important Notes
  10.     - Supports the # operator for both mixed and non mixed tables
  11.     - Can be iterated, but!
  12.     - DOES NOT support ipairs() or pairs(), only the generic iterator.
  13.     - Even with the attempted optimizations, the library functions can be
  14.       up to 10x slower than luau's table library... directly index when
  15.       possible!: yourTable[k] = v
  16.      
  17.     Reserved names/keys:
  18.     these are names YOU CAN'T index to your wrapped table, they've been _full_snake_cased_
  19.     to minimize chances of the dev having name collisions however... If you're
  20.     casing your keys/variables like that on a regular basis, seek jesus :v
  21.    
  22.     - __metadata  
  23.     - __onRemoved
  24.     - __onInserted
  25.     - __onReplaced
  26.     - __size
  27.     - __actualTable
  28.    
  29.    
  30.     Warning: a wrapped table is Incompatible with luau's table library DO NOT cross use them!
  31.     the internal metadata of the table will be lost/go out of date (which means this has to be expanded.)
  32. --]]
  33.  
  34. local function nop() end
  35.  
  36. local cupboard = {}
  37.  
  38. -- !== ================================================================================||>
  39. -- !== Proxy
  40. -- !== ================================================================================||>
  41. --[[
  42.     the proxyHandler is the star of the show, it's a metatable that allows me to
  43.     detect insertions/removals from a given wrapped table through the proxy pattern
  44.     https://www.lua.org/pil/13.4.4.html
  45.  
  46.     TL;DR: __newindex won't be called if the given key already exists in the table,
  47.     so setting a metatable to a table to track accesses is NOT ENOUGH.
  48.    
  49.     but we can loophole this by using an empty table as a proxy so __newindex ALWAYS
  50.     fires which indeed allows me to detect table accesses and do all the handling
  51.     required for this usecase, allowing me to make a table reactive.
  52.  
  53.     in this case the proxy wraps the given table, hence why the constructor is
  54.     named wrap.
  55. ]]
  56. local proxyHandler = {}
  57.  
  58. proxyHandler.__index = function(t, k)
  59.     local actualTable = rawget(t, "__actualTable")
  60.     return actualTable[k]
  61. end
  62.  
  63. proxyHandler.__newindex = function(t, k, v)
  64.     local actualTable = rawget(t, "__actualTable")
  65.     local metadata = rawget(t, "__metadata")
  66.    
  67.     -- order matters, gotta catch callbacks first else they'll get
  68.     -- indexed to actualTable.
  69.     if k == "__onRemoved" or k == "__onInserted" or k == "__onReplaced" and type(v) == "function" then
  70.         assert(type(v) == "function", "Unable to assign callback " .. k .. " function expected got " .. typeof(v))
  71.         metadata[k] = v
  72.         return
  73.     end
  74.  
  75.     if actualTable[k] ~= nil and v == nil then
  76.         local oldVal = actualTable[k]
  77.         actualTable[k] = nil
  78.         metadata.__size -= 1
  79.         metadata.__onRemoved(t, k, oldVal)
  80.         return
  81.     end
  82.  
  83.     if actualTable[k] == nil then
  84.         actualTable[k] = v
  85.         metadata.__size += 1
  86.         metadata.__onInserted(t, k, v)
  87.         return
  88.     end
  89.  
  90.     -- Not incrementing __size here cause it's a replacement case
  91.     -- no "true" insertion occured
  92.     if actualTable[k] ~= nil and actualTable[k] ~= v then
  93.         actualTable[k] = v
  94.         metadata.__onReplaced(t, k, v)
  95.         return
  96.     end
  97. end
  98.  
  99. proxyHandler.__tostring = function(t)
  100.     local actualTable = rawget(t, "__actualTable")
  101.     print(actualTable)
  102.     return ""
  103. end
  104.  
  105. proxyHandler.__iter = function(t)
  106.     local actualTable = rawget(t, "__actualTable")
  107.     return next, actualTable
  108. end
  109.  
  110. proxyHandler.__len = function(t)
  111.     local metadata = rawget(t, "__metadata")
  112.     return metadata.__size
  113. end
  114.  
  115. -- !== ================================================================================||>
  116. -- !== Library
  117. -- !== ================================================================================||>
  118. cupboard.wrap = function(
  119.     t: { [any]: any },
  120.     shouldCallOnInserted: boolean, -- calls onInserted for each item in the given table
  121.     callbacks
  122. ): {
  123.     __onRemoved: (t: { [any]: any }, k: any, v: any) -> nil,
  124.     __onInserted: (t: { [any]: any }, k: any, v: any) -> nil,
  125.     __onReplaced: (t: { [any]: any }, k: any, v: any) -> nil,
  126.     }
  127.     -- Assertion pass to verify types.
  128.  
  129.     assert(type(t) == "table", "bad argument t, it must be of type table!")
  130.     assert(type(shouldCallOnInserted) == "boolean", "bad argument shouldFire, it must be of type boolean!")
  131.     assert(
  132.         type(callbacks) == "table" or type(callbacks) == "nil",
  133.         "bad argument onInserted, it must be of type table or nil!"
  134.     )
  135.  
  136.     callbacks = callbacks or {}
  137.  
  138.     assert(
  139.         type(callbacks.onInserted) == "function" or type(callbacks.onInserted) == "nil",
  140.         "bad argument onInserted, it must be of type function!"
  141.     )
  142.  
  143.     assert(
  144.         type(callbacks.onRemoved) == "function" or type(callbacks.onRemoved) == "nil",
  145.         "bad argument OnRemoved, it must be of type function!"
  146.     )
  147.  
  148.     assert(
  149.         type(callbacks.onReplaced) == "function" or type(callbacks.OnReplaced) == "nil",
  150.         "bad argument OnReplaced, it must be of type function!"
  151.     )
  152.  
  153.     -- stash all of the given t key value pairs in another table and delete them
  154.     -- from t, this is because t will now become an empty proxy for the metamethods
  155.     -- to fire on it, and easily allows to wrap an already existing table
  156.     local actualTable = {}
  157.     local initialTableSize = 0
  158.  
  159.     for k, v in t do
  160.         actualTable[k] = v
  161.         initialTableSize += 1
  162.         t[k] = nil
  163.     end
  164.  
  165.     -- make t a proxy and initialize the its metadata
  166.     t.__actualTable = actualTable
  167.     t.__metadata = { --> metadata's not inside actualTable so it's not counted as a value of it
  168.         __onRemoved = callbacks.onRemoved or nop,
  169.         __onInserted = callbacks.onInserted or nop,
  170.         __onReplaced = callbacks.onReplaced or nop,
  171.         __size = initialTableSize, --> optimization to track the size of the table, else inserting would be borderline un-useable
  172.     }
  173.  
  174.     setmetatable(t, proxyHandler)
  175.  
  176.     -- this is done at this step for safety so any key, value from t can be
  177.     -- safely reached in the callback w/o it being nil cause it was not inserted
  178.     -- yet
  179.     if shouldCallOnInserted == true and callbacks.onInserted then
  180.         for k, v in actualTable do
  181.             callbacks.onInserted(t, k, v)
  182.         end
  183.     end
  184.  
  185.     return t
  186. end
  187.  
  188. cupboard.insert = function(t: { [any]: any }, v: any, pos: number?)
  189.     assert(type(t) == "table", "bad argument t, it must be of type table!")
  190.     assert(type(pos) == "number" or type(pos) == "nil", "bad argument pos, it must be of type number or nil!")
  191.  
  192.     if pos then
  193.         -- Get the "true" table so we can bypass the metamethod calls, not doing
  194.         -- it would cause _on_inserted_ to be called every time a value is shifted
  195.         local actualTable = rawget(t, "__actualTable")
  196.         local metadata = rawget(t, "__metadata")
  197.  
  198.         -- right shift vals
  199.         for i = metadata.__size, pos, -1 do
  200.             actualTable[i + 1] = actualTable[i]
  201.         end
  202.  
  203.         -- index the new value in the desired position using the given
  204.         -- table so _on_inserted_ is called.
  205.         t[pos] = v
  206.         metadata.__size += 1
  207.     else
  208.         t[#t + 1] = v
  209.     end
  210. end
  211.  
  212. cupboard.remove = function(t, pos: any)
  213.     assert(type(t) == "table", "bad argument t, it must be of type table!")
  214.     assert(type(pos) == "number", "bad argument key, it must be of type number!")
  215.     local actualTable = rawget(t, "__actualTable")
  216.     local metadata = rawget(t, "__metadata")
  217.     local len = metadata.__size
  218.  
  219.     -- t[k] = nil leaves a gap in the table, which would break array functionality
  220.     -- so the values gotta be left shifted to fill the gap, also do this on the
  221.     -- given table so _on_removed_ is called.
  222.     t[pos] = nil
  223.  
  224.     -- left shift vals
  225.     for i = pos, len, 1 do
  226.         actualTable[i] = actualTable[i + 1]
  227.     end
  228. end
  229.  
  230. cupboard.find = function(t, v, init: number?)
  231.     local actualTable = rawget(t, "__actualTable")
  232.     local metadata = rawget(t, "__metadata")
  233.     init = init or 1
  234.     for i = init, metadata.__size do
  235.         if actualTable[i] == v then
  236.             return i
  237.         end
  238.     end
  239.  
  240.     return nil
  241. end
  242.  
  243. cupboard.unwrap = function(t)
  244.     local actualTable = rawget(t, "__actualTable")
  245.     setmetatable(t, nil)
  246.     t = nil
  247.     return actualTable
  248. end
  249.  
  250.  
  251. --[[
  252.  
  253.  Sets a callback for metadata in a cupboard.
  254.  
  255.  This function allows you to set a callback for a specific metadata event in a
  256.  cupboard table. It transforms the input `name` into the appropriate callback format,
  257.  whether it includes the "on" prefix or is in camelCase, and assigns the provided
  258.  `callback` to the corresponding field in the metadata table.
  259.  
  260.  so any of these inputs are valid:
  261.     - onInserted
  262.     - OnInserted
  263.     - Inserted
  264.     - inserted
  265.  
  266. which applies the same for the other callbacks
  267. ]]
  268. cupboard.setCallback = function(t: {[any]: any}, name: string, callback: (t: {[any]: any}, key: any, value: any) -> nil)
  269.     local metadata = rawget(t, "__metadata")
  270.    
  271.     -- Convert the input name to PascalCase
  272.     local formattedName = name:gsub("^%l", string.upper)
  273.  
  274.    
  275.     -- Remove trailing underscores
  276.     local formattedName = name:gsub("^%l", string.upper)
  277.  
  278.     -- Check if the name starts with "on" and remove it
  279.     if formattedName:sub(1, 2) == "On" then
  280.         formattedName = formattedName:sub(3)
  281.     end
  282.  
  283.     -- Append "__on" to the formatted name
  284.     local callbackName = "__on" .. formattedName
  285.    
  286.     if metadata[callbackName] then
  287.         metadata[callbackName] = callback
  288.     else
  289.         error(name.." is not a valid callback name, callback names are: Removed, Inserted, Replaced.")
  290.     end
  291.     -- Set the callback in the metadata table
  292.     metadata[callbackName] = callback
  293. end
  294.  
  295. return cupboard
  296.  
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement