AttributeManager - API Wrapper For Attributes

I dislike the attributes API.

I think the idea behind attributes is great. Being able to create values specific to objects without the use of ValueBases seems like a dream come true. However, there are some pretty nasty issues within the API that don’t really make sense. Here’s the list of things I gripe about:

  • Instance::GetAttribute has the capability to return 0 values instead of just nil, which means you can easily produce unsafe code. e.g table.insert(t, instance:GetAttribute("AAA")) (assuming the attribute doesn’t exist)

  • The no “RBX” prefix rule seems pointless. There’s probably a better way for Roblox to stop people from changing attributes set by CoreScripts. Not to mention Roblox managed attributes are few and far between to my knowledge.

  • The inability to save certain datatypes to attributes that could very easily be serialized like a CFrame is pretty disappointing. It doesn’t make sense to me why attributes would be much more restricting than ValueBases considering they are the alternative for on the fly variable adjustment.

Instead of sitting and complaining though, I decided to write my own API wrapper that fully emulates and adds features on top of the existing API.

Introducing AttributeManager, the one stop solution for lifting some of those nasty restrictions on attribute names and values. Please note that I only specified some restrictions. There were some hard limits and other things that I am not clever enough to get around that I will go over later.

Lets start with the API provided by the module. That’s the beautiful part though. It is almost exactly the same as the Roblox API. Every function has been recreated to behave how you’d expect the normal API to behave minus some minor QOL tweaks. The most notable of the tweaks is ManagerObj::GetAttribute is guaranteed to return at least 1 value. This means code like table.insert(t, ManagerObj:GetAttribute("AAA")) is no longer unsafe. Here’s a list of the actual modified API:

AttributeManager.new(instanceToWrap: Instance): ManagerObject
     Creates a new ManagerObject that contains the wrapper functions

ManagerObject:BulkSet(t: {[string]: any})
    Creates new attributes from every key-value pair in the dictionary t

ManagerObject:Destroy()
    Cleans up connections and objects created by the ManagerObject

ManagerObject:GetAttribute(name: string): any
    Functionally similar to Instance::GetAttribute but guarantees at least 1 return value for safe use

For the rest of the API, you can refer to the Developer Wiki page for Instance as the rest of the ManagerObject functions perfectly emulate the parameters and return values of those attribute functions.

I also implement custom serialization routines for certain datatypes. With AttributeManager it is now possible to save CFrames, tables and Instances to attributes with some restrictions. Tables are JSON encoded so you can only really have primitives inside the serialized table to have it work correctly but its better than nothing. Instances also use a mechanism that is similar to how RemoteEvents serialize Instances where it is done by an ID. Instance attributes will replicate over the Client-Server boundary correctly assuming the client or server can index the part in question. Otherwise, ManagerObject::GetAttribute will just return nil.

As for caveats, here is a list of the ones I know about.

  • Attribute names are limited to 97 characters (because of how the manager stores the attributes) and still cannot contain special characters like & or * with an exception for underscore.

  • While AttributeManager can still interface with attributes not created through ManagerObj::SetAttribute, ManagerObj::GetAttribute will still prioritize managed attributes over unmanaged ones. This means that if you create an attribute manually called “test” and then create one through AttributeManager’s API, ManagerObj::GetAttribute will always return the value of the one created by the module.

  • Because of how ManagerObj.AttributeChanged and ManagerObj::GetAttributeChangedSignal are implemented, changes to unmanaged attributes that are named the same thing as managed attributes will still fire the signal.

If you wish to try out this module, I uploaded it to Roblox here. I will also include the source code below just in case you want to look at that or ask questions about how things are implemented. If you have any questions or comments let me know and I’ll try to get back to you as fast as possible. :slightly_smiling_face:

Source Code
local AttributeManager = {
	prototype = {}
}
AttributeManager.__index = AttributeManager.prototype

local Http = game:GetService("HttpService")
local Collection = game:GetService("CollectionService")

local conversion  = {
	["table"] = function(mode, t)
		if mode == "set" then
			return "AM_table | "..Http:JSONEncode(t)
		elseif mode == "get" then
			return Http:JSONDecode(t)
		end
	end,

	["CFrame"] = function(mode, cf)
		if mode == "set" then
			return "AM_CFrame | "..Http:JSONEncode({cf:GetComponents()})
		elseif mode == "get" then
			return CFrame.new(unpack(Http:JSONDecode(cf)))
		end
	end,
	
	["Instance"] = function(mode, inst)
		if mode == "set" then
			local uuid = Http:GenerateGUID(false)
			Collection:AddTag(inst, uuid)
			return "AM_Instance | "..uuid
		elseif mode == "get" then
			return Collection:GetTagged(inst)[1]
		end
	end,

	native = {
		"string",
		"boolean",
		"number",
		"UDim",
		"UDim2",
		"BrickColor",
		"Color3",
		"Vector2",
		"Vector3",
		"NumberSequence",
		"ColorSequence",
		"NumberRange",
		"Rect",
		"nil"
	}
}

local function serialize(value)
	local toSerialize, isNative = conversion[typeof(value)], table.find(conversion.native, typeof(value))
	if not toSerialize and not isNative then
		error("Value cannot be serialized. No conversion exists for type "..typeof(value), 3)
	end
	return (not isNative) and toSerialize("set", value) or value
end

local function deserialize(vtype: string, str: string)
	local toSerialize, isNative = conversion[vtype], table.find(conversion.native, vtype)
	
	if not toSerialize and not isNative then
		error("Value cannot be deserialized. No conversion exists for type "..vtype, 3)
	end
	return isNative and str or toSerialize("get", str)
end

local function prefix(name: string)
	return string.format("AM_%s", name)
end

function AttributeManager.prototype:SetAttribute(name: string, value: any)
	self.Instance:SetAttribute(prefix(name), serialize(value)) --no error handling because roblox error is good
end

function AttributeManager.prototype:BulkSet(t: {[string]: any})
	for k,v in pairs(t) do	
		self.Instance:SetAttribute(prefix(k), serialize(v))
	end
end

function AttributeManager.prototype:GetAttribute(name: string)
	local real = prefix(name)
	local val = self.Instance:GetAttribute(real)

	if not val then --no prefixed attribute, check for non managed and force a return value
		return self.Instance:GetAttribute(name) or nil
	end

	if type(val) == "string" then  --managed attributes can return non strings for native types
		local isSerialized, rawValue = val:match("^AM_(.-) | (.+)")
		return deserialize(isSerialized or "string", rawValue)
	end

	return val --catch for native and managed types
end

function AttributeManager.prototype:GetAttributes()
	local rawAttr = self.Instance:GetAttributes()
	local clone = table.create(#rawAttr)

	for k,v in pairs(rawAttr) do
		local isManaged = k:match("^AM_(.+)")

		if not isManaged then
			clone[k] = v --non managed attributes will never have to be serialized
		else
			if type(v) == "string" then
				local isSerialized, rawValue = v:match("^AM_(.-) | (.+)")
				local converted = deserialize(isSerialized or "string", rawValue)
				clone[isManaged] = converted
			else 
				clone[isManaged] = v -- given value is native (non-string)
			end
		end
	end
	
	return clone
end

function AttributeManager.prototype:GetAttributeChangedSignal(name: string)
	if self._signals[name] then
		return self._signals[name].event --mimics the caching behavior of real GACS
	end

	local event, container = Instance.new("BindableEvent"), {}
	container.event = event.Event
	container.obj = event
	self._signals[name] = container

	return container.event
end

function AttributeManager.prototype:Destroy()
	self._mgr:Disconnect()
	
	for k,v in pairs(self._signals) do
		v.obj:Destroy()
	end

	for k,v in pairs(self) do
		self[k] = nil --i probably dont need to do this but better safe than sorry
	end
end

function AttributeManager.new(inst: Instance)
	local obj = {
		Instance = inst;
		_attrchanged = Instance.new("BindableEvent"),
		_signals = {},
	}

	obj.AttributeChanged = obj._attrchanged.Event

	-- this connection is fine because it will be cleaned up on destroy
	obj._mgr = obj.Instance.AttributeChanged:Connect(function(name)
		local isManaged = name:match("^AM_(.+)")
		local resolvedName = isManaged or name
		local hasSignal = obj._signals[resolvedName]

		if hasSignal then
			hasSignal.obj:Fire(resolvedName)
		end

		obj._attrchanged:Fire(resolvedName)
	end)

	return setmetatable(obj, AttributeManager)
end

return AttributeManager
Usage Example
local AttributeManager = require(path.to.module)
local inst = AttributeManager.new(workspace)

inst:SetAttribute("RBXTest", workspace.Baseplate)

print(inst:GetAttribute("RBXTest") == workspace.Baseplate) --> true
11 Likes

I don’t see the use in a module that is just a Attribute API wrapper but overcomplicated.

3 Likes

Not only is it not complicated since its almost a perfect copy of Roblox’s version without much change to the end user, but the point is to fix bugs and complaints about the API that still haven’t been fixed. The entire purpose is to extend functionality without having to learn a whole new API. If this module is complicated then I guess Roblox’s API is complicated too?

2 Likes

It’s overcomplicated in terms of code, not what the person using it will see.

Lua isn’t JavaScript, you don’t have a “prototype” in a table:
chrome_SLAUhzzyNG

100% a perfect copy that is 100% not a overcomplicated wrapper:
chrome_T9W3V1ygrS

???
chrome_d5UcsYDG89
chrome_PpBvBHe1qK

Waste of space:
chrome_kYZDeiMVRK

Who would actually ever call :Destroy() on a table?
image

1 Like

The reason I use a prototype is to stop the constructor from leaking into the object through __index. Being able to do AttributeManager.new().new().new().new() is a side effect of the old and tired OOP pattern in Lua. It affects the end user in no way and only serves to keep the methods compartmentalized. The name is just borrowed from JavaScript because of a lack of a better name. As for the rest of the code critique, sure. I left in some useless stuff because I only gave the code a couple passes when I was making it neat.

At the end of the day you won’t be reading any of this under normal circumstances. I also don’t get what you dont understand about the serialization dictionary. Its quite literally just mapping values from typeof to functions to serialize and deserialize the given datatype.

The reason ManagerObj::Destroy exists is to clean up the connections that you don’t have access to (and the ones you do) in a quick and convenient manner. Just like Instance::Destroy does. Without it, creating ManagerObjects and dereferencing them would leak memory. Look at the connection that is made in the constructor.

Most of your criticism just boils down to not understanding the abstraction. I abstracted most of this because the module is doing stuff behind the scenes to avoid the normal restrictions of attributes. The implementation of this shouldn’t matter to the end user which is exactly why I abstracted it. The code is “complicated” because I’m jumping through hoops to get around Roblox’s restrictions.

10 Likes

@wynnrar I understand why and what you are doing, I did the same stuff for my module as well

So I’m certain that your module is solid :+1:t2:

Good job, you even have table and CFrame support, nice!


@Marimariius I think you should be careful with your criticisms, especially when you don’t understand the topic well, it is more polite to ask questions when you are unsure and don’t come off as offensive.

4 Likes

Ooh wow. I didn’t know this even existed but this is super neat. I didn’t even think about something like IsSupportedType as a public method. A lot of your module is like the holy grail for attribute power users. I am glad you understand where i’m coming from though, attributes do feel pretty underpowered compared to ValueBases. :sweat_smile:

1 Like

I don’t believe any sort of idiot protection is useless. Making your code more solid is something you should strive for in my opinion.

I sincerely hope you understand that the statement ":SetAttribute() does not require serialization" is completely false even outside of this module. Roblox already serializes every valid value passed into Instance::SetAttribute because its saved into the place file as a binary string AND it has to replicate. I’m doing the exact same thing but with a less sophisticated serialization method. I’m sure you can find use cases for wanting to save a table or CFrame to an attribute somewhere.

As for the use of ManagerObj::Destroy, you’d call it in the same cases where you’d call Instance::Destroy. You use it when you’re done with the attribute manager and want to clean up the object and its connections.

Your last comment is also just ignorant beyond belief. Putting people down for no reason doesn’t make your criticism valid it just makes you look like a dork. If you don’t like the module and you don’t like the way I program just don’t use my stuff. It’s really that simple.

5 Likes

:Destroy() disconnects all connections related to an Instance. You do not need :Destroy() on the module.

Except for when you do. Having a destroy method allows you to facade the entire process of clearing metatables, releasing internal event connections, and clearing the primary table itself. I’d even reckon that it’s good practice to have a Destroy method.

2 Likes

i don’t remember tables being instances lo

What? You yourself said this:

You do not need :Destroy() on the module.

1 Like

Actually, ManagerObj::Destroy serves an important purpose. It is the only way to disconnect connections managed by the object without interacting with “private” variables. Relying on internal variables is a product of bad design and having a Destroy method just rounds out the rough edges.

6 Likes

Absolutely incorrect. In many cases, it’s perfect practice to have a deconstructor to match a constructor. This applies both in and out of Lua, and is a basic computer science principle.

I like this wrapper for attributes, as it gives you more flexibility working with attributes and handles a few edge cases (which can possibly break code if you rely on some behavior from attributes) as well as providing more useful functionality (the support for other data types that attributes don’t yet support), and the first and last points you gripe about that this wrapper fixes, which I found useful.

That being said, the only gripe I have is that there is no support for Deferred events (as you use bindable events) which are currently in beta, but will soon become the default signal behavior. Other developers would find it a lot more useful if it handled some edge cases of that signal behavior implicitly, rather than developers having to handle it them selves explicitly for the sake of convenience.