--!strict --[[ Constructs a new ForValues object which maps values of a table using a `processor` function. Optionally, a `destructor` function can be specified for cleaning up values. If omitted, the default cleanup function will be used instead. Additionally, a `meta` table/value can optionally be returned to pass data created when running the processor to the destructor when the created object is cleaned up. ]] local PubTypes = require "../PubTypes" local Types = require "../Types" -- Logging local parseError = require "../Logging/parseError" local logError = require "../Logging/logError" local logErrorNonFatal = require "../Logging/logErrorNonFatal" local logWarn = require "../Logging/logWarn" -- Utility local cleanup = require "../Utility/cleanup" local needsDestruction = require "../Utility/needsDestruction" -- State local peek = require "../State/peek" local makeUseCallback = require "../State/makeUseCallback" local isState = require "../State/isState" local class = {} local CLASS_METATABLE = { __index = class } local WEAK_KEYS_METATABLE = { __mode = "k" } --[[ Called when the original table is changed. This will firstly find any values meeting any of the following criteria: - they were not previously present - a dependency used during generation of this value has changed It will recalculate those values, storing information about any dependencies used in the processor callback during value generation, and save the new value to the output array with the same key. If it is overwriting an older value, that older value will be passed to the destructor for cleanup. Finally, this function will find values that are no longer present, and remove their values from the output table and pass them to the destructor. You can re-use the same value multiple times and this will function will update them as little as possible; reusing the same values where possible. ]] function class:update(): boolean local inputIsState = self._inputIsState local inputTable = peek(self._inputTable) local outputValues = {} local didChange = false -- clean out value cache self._oldValueCache, self._valueCache = self._valueCache, self._oldValueCache local newValueCache = self._valueCache local oldValueCache = self._oldValueCache -- table.clear(newValueCache) for i, _ in pairs(newValueCache) do newValueCache[i] = nil end -- clean out main dependency set for dependency in pairs(self.dependencySet) do dependency.dependentSet[self] = nil end self._oldDependencySet, self.dependencySet = self.dependencySet, self._oldDependencySet -- table.clear(self.dependencySet) for i, _ in pairs(self.dependencySet) do self.dependencySet[i] = nil end -- if the input table is a state object, add it as a dependency if inputIsState then self._inputTable.dependentSet[self] = true self.dependencySet[self._inputTable] = true end -- STEP 1: find values that changed or were not previously present for inKey, inValue in pairs(inputTable) do -- check if the value is new or changed local oldCachedValues = oldValueCache[inValue] local shouldRecalculate = oldCachedValues == nil -- get a cached value and its dependency/meta data if available local value, valueData, meta if type(oldCachedValues) == "table" and #oldCachedValues > 0 then local valueInfo = table.remove(oldCachedValues, #oldCachedValues) value = valueInfo.value valueData = valueInfo.valueData meta = valueInfo.meta if #oldCachedValues <= 0 then oldValueCache[inValue] = nil end elseif oldCachedValues ~= nil then oldValueCache[inValue] = nil shouldRecalculate = true end if valueData == nil then valueData = { dependencySet = setmetatable({}, WEAK_KEYS_METATABLE), oldDependencySet = setmetatable({}, WEAK_KEYS_METATABLE), dependencyValues = setmetatable({}, WEAK_KEYS_METATABLE), } end -- check if the value's dependencies have changed if shouldRecalculate == false then for dependency, oldValue in pairs(valueData.dependencyValues) do if oldValue ~= peek(dependency) then shouldRecalculate = true break end end end -- recalculate the output value if necessary if shouldRecalculate then valueData.oldDependencySet, valueData.dependencySet = valueData.dependencySet, valueData.oldDependencySet -- table.clear(valueData.dependencySet) for i, _ in pairs(valueData.dependencySet) do valueData.dependencySet[i] = nil end local use = makeUseCallback(valueData.dependencySet) -- local processOK, newOutValue, newMetaValue = -- xpcall(self._processor, parseError, use, inValue) local processOK, newOutValue, newMetaValue = pcall(self._processor, use, inValue) if processOK then if self._destructor == nil and ( needsDestruction(newOutValue) or needsDestruction(newMetaValue) ) then logWarn "destructorNeededForValues" end -- pass the old value to the destructor if it exists if value ~= nil then local destructOK, err = pcall(self._destructor or cleanup, value, meta) if not destructOK then logErrorNonFatal( "forValuesDestructorError", parseError(err) ) end end -- store the new value and meta data value = newOutValue meta = newMetaValue didChange = true else -- restore old dependencies, because the new dependencies may be corrupt valueData.oldDependencySet, valueData.dependencySet = valueData.dependencySet, valueData.oldDependencySet logErrorNonFatal( "forValuesProcessorError", parseError(newOutValue) ) end end -- store the value and its dependency/meta data local newCachedValues = newValueCache[inValue] if newCachedValues == nil then newCachedValues = {} newValueCache[inValue] = newCachedValues end table.insert(newCachedValues, { value = value, valueData = valueData, meta = meta, }) outputValues[inKey] = value -- save dependency values and add to main dependency set for dependency in pairs(valueData.dependencySet) do valueData.dependencyValues[dependency] = peek(dependency) self.dependencySet[dependency] = true dependency.dependentSet[self] = true end end -- STEP 2: find values that were removed -- for tables of data, we just need to check if it's still in the cache for _oldInValue, oldCachedValueInfo in pairs(oldValueCache) do for _, valueInfo in ipairs(oldCachedValueInfo) do local oldValue = valueInfo.value local oldMetaValue = valueInfo.meta local destructOK, err = pcall(self._destructor or cleanup, oldValue, oldMetaValue) if not destructOK then logErrorNonFatal("forValuesDestructorError", parseError(err)) end didChange = true end -- table.clear(oldCachedValueInfo) for i, _ in pairs(oldCachedValueInfo) do oldCachedValueInfo[i] = nil end end self._outputTable = outputValues return didChange end --[[ Returns the interior value of this state object. ]] function class:_peek(): any return self._outputTable end function class:get() logError "stateGetWasRemoved" end local function ForValues( inputTable: PubTypes.CanBeState<{ [any]: VI }>, processor: (VI) -> (VO, M?), destructor: (VO, M?) -> ()? ): Types.ForValues local inputIsState = isState(inputTable) local self = setmetatable({ type = "State", kind = "ForValues", dependencySet = {}, -- if we held strong references to the dependents, then they wouldn't be -- able to get garbage collected when they fall out of scope dependentSet = setmetatable({}, WEAK_KEYS_METATABLE), _oldDependencySet = {}, _processor = processor, _destructor = destructor, _inputIsState = inputIsState, _inputTable = inputTable, _outputTable = {}, _valueCache = {}, _oldValueCache = {}, }, CLASS_METATABLE) self:update() return self end return ForValues