Detecting when a nested table is indexed or changed using metamethods

By creating an empty proxy table and linking it to a table containing data, we can fire an event whenever the proxy table is indexed or an index is set to some value.

local internalTable = {}	
internalTable.Changed = Instance.new("BindableEvent")
local proxy = {}

setmetatable(proxy, {__index = function(self, index) 
	return internalTable [index]
end, __newindex = function(self, index, value) 
	internalTable [index] = value
	internalTable.Changed:Fire(index)
end})

proxy.Value = 5 -- Would fire the Changed event

However, the metamethods don’t fire if we index a nested table.

local internalTable = {
	somethingInside = {
		aValue = 5,
	}
}

proxy.somethingInside.aValue = 10 -- Doesn't fire the event

Is there a way to make the metamethods fire if we change parts of tables nested within the table?

1 Like

Metamethods don’t automatically nest, otherwise it’d be a disaster waiting to happen, at least performance wise.

You’d need to apply the metatable to each nested table, as far as I know. However, if this is incorrect, someone should correct me.

1 Like
Hmm I managed to get intercept __index recursively... ish... there's probably some cases where it doesn't work, and a determined user could definitely break it.
local outer = {
    value = 1,
    inner = {
        value = 2,
        deeper = {
            value = 3,
            weMustGoDeeper = {
                value = 4,
            },
        },
    },
}

function interceptIndexing(t, interceptor)
    local interface = {}
    setmetatable(interface, {
        __index = function(_, k)
            interceptor(t, k)
            local v = rawget(t, k)
            if type(v) == "table" then
                return interceptIndexing(v, interceptor)
            else
                return v
            end
        end,
        __eq = function(t1, t2)
            if rawequal(t1, t2) then return true end

            --Ensure t1 is interface
            if rawequal(interface, t2) then
                t1, t2 = t2, t1
            end

            if rawequal(t2, t) then return true end

            return false
        end
    })
    return interface
end

local interface = interceptIndexing(outer, function(t, k)
    print("I '" .. k .. "' on " .. tostring(t))
end)
print("R", interface.value)
print("R", interface.inner.value)
print("R", interface.inner.deeper.value)
print("R", interface.inner.deeper.weMustGoDeeper.value)

print("Eq", interface == outer)
print("Eq", interface.inner == outer.inner)

Aaaand…

...here's my attempt at recursive __newindex intercepting:
function interceptIndexing(t, interceptIndex, interceptNewindex)
    local interface = {}
    return setmetatable(interface, {
        __index = function(_, k)
            interceptIndex(t, k)
            local v = rawget(t, k)
            if type(v) == "table" then
                return interceptIndexing(v, interceptIndex, interceptNewindex)
            else
                return v
            end
        end,
        __newindex = function (_, k, v)
            interceptNewindex(t, k, v)
            rawset(t, k, v)
        end,
        __eq = function(t1, t2)
            if rawequal(t1, t2) then return true end

            --Ensure t1 is interface
            if rawequal(interface, t2) then
                t2 = t1
            end

            if rawequal(t2, t) then return true end

            return false
        end
    })
end
Here's my test data:
function namedTable(name, t) --so I don't have to compare table addresses >.<'
    local t = t or {}
    return setmetatable(t, {
        __tostring = function(t)
            return name
        end
    })
end

local outer = namedTable("tOuter", {
    value = 1,
    inner = namedTable("tInner", {
        value = 2,
        deeper = namedTable("tDeeper", {
            value = 3,
            weMustGoDeeper = namedTable("tWeMustGoDeeper", {
                value = 4,
            }),
        }),
    }),
})
And here are my tests:
local interface = interceptIndexing(outer, function(t, k)
    print("index    '" .. k .. "' on '" .. tostring(t) .. "'")
end, function(t, k, v)
    print("newindex '" .. k .. "' = " .. tostring(v) .. " on '" .. tostring(t) .. "'")
end)
print("R", interface.value)
print("R", interface.inner.value)
print("R", interface.inner.deeper.value)
print("R", interface.inner.deeper.weMustGoDeeper.value)

print("Eq", interface == outer)
print("Eq", interface.inner == outer.inner)

interface.value = 'a'
print("R", interface.value)
interface.inner.value = 'b'
print("R", interface.inner.value)
interface.inner.deeper.weMustGoDeeper.value = 'd'
print("R", interface.inner.deeper.weMustGoDeeper.value)

Well… it doesn’t actually intercept because the interceptor functions don’t determine the actual result of indexing and/or settin :confused:

Here's a version that actually intercepts things, and my insane test cases that actually show some pretty cool possibilities
local outer = namedTable("tOuter", {
    valueAyy = 1,
    innerAyy = namedTable("tInner", {
        valueAyy = 2,
        deeperAyy = namedTable("tDeeper", {
            valueAyy = 3,
            weMustGoDeeperAyy = namedTable("tWeMustGoDeeper", {
                valueAyy = 4,
            }),
        }),
    }),
})

function interceptIndexing(t, interceptIndex, interceptNewindex)
    local interface = {}
    return setmetatable(interface, {
        __index = function(_, k)
            local v = interceptIndex(t, k)
            if type(v) == "table" then
                return interceptIndexing(v, interceptIndex, interceptNewindex)
            else
                return v
            end
        end,
        __newindex = function (_, k, v)
            interceptNewindex(t, k, v)
        end,
        __eq = function(t1, t2)
            if rawequal(t1, t2) then return true end

            --Ensure t1 is interface
            if rawequal(interface, t2) then
                t2 = t1
            end

            if rawequal(t2, t) then return true end

            return false
        end
    })
end

local interface = interceptIndexing(outer, function(t, k)
    print("index    '" .. k .. "' on '" .. tostring(t) .. "'")
    if k == 'mustNotBeIndexed' then
        error(k)
    end
    return rawget(t, k .. "Ayy")
end, function(t, k, v)
    print("newindex '" .. k .. "' = " .. tostring(v) .. " on '" .. tostring(t) .. "'")
    if k == 'mustNotBeOverwritten' then
        error(k)
    elseif k == 'mustBe0Or1' then
        if v == 0 or v == 1 then
            rawset(t, k, v)
        else
            error(k)
        end
    else
        rawset(t, k .. "Ayy", v .. "+You got INTERCEPTED! 😎")
    end
end)

print("E", pcall(function()
    return interface.inner.mustNotBeIndexed
end))

print("E", pcall(function()
    interface.inner.mustNotBeOverwritten = "swag swag"
end))

print("E", pcall(function()
    interface.inner.mustBe0Or1 = 1
    return "mustBe0Or1"
end))

print("E", pcall(function()
    interface.inner.mustBe0Or1 = 2
end))

print("R", interface.value)
print("R", interface.inner.value)
print("R", interface.inner.deeper.value)
print("R", interface.inner.deeper.weMustGoDeeper.value)

print("Eq", interface == outer)
print("Eq", interface.inner == outer.inner)

interface.value = 'a'
print("R", interface.value)
interface.inner.value = 'b'
print("R", interface.inner.value)
interface.inner.deeper.weMustGoDeeper.value = 'd'
print("R", interface.inner.deeper.weMustGoDeeper.value)

All versions have the problem that they create a new interface to each nested table every time they’re accessed. AFAICT this shouldn’t leak memory, but it does probably impact performance quite a bit. The advantage is that it’s completely dynamic, so you could add or remove nested tables and it should still intercept correctly. I’m not 100% sure but my intuition tells me that memoization would work here to potentially make it faster.