Preface
As a Roblox developer, it is currently too difficult to consistently find and react to instance existence based on a query in a scalable and unified manner. Right now we have QueryDescendants but we still need to combine other separate behaviors (ex: ChildAdded, TagAdded, AttributeChanged, custom scans, manual string matching) for common use case, which can be error-prone and hard to maintain in large projects.
Problem
The QueryDescendants method is a good foundation, but it falls short in situations where a more expressive querying model becomes necessary. It only evaluates based on current DataModel state. In experiences where content is replicated/streamed to the client it becomes essential to implement systems reactively. Currently, if I want to respond to any descendant matching some query when it exists, the approach is scattered and may not be so clear depending on the implementation:
- connect
ChildAdded/DescendantAdded - call
QueryDescendantsinside the callback - search (
table.find) for the new instance
The way to check if one instance matches a query, as shown above, is to call QueryDescendants and then table.find the instance in the resulting array. Additionally, there is no current behavior that allows simple yielding until an instance exists matching some query. The workaround for this would involve a ChildAdded/DescendantAdded connection, polling, and thread management. Aside from unneeded complexity, a speculation on these workarounds would be increased memory allocation and potential performance degradation compared to a one unified, built-in approach.
In addition to the the problems listed above, the current query syntax lacks an essential feature: partial pattern matching. Many Roblox projects of mine and in general rely on naming conventions or classifications of objects. For example:
- GUI elements:
Entry_1,Entry_2, âŚEntry_* - prefixes:
Right_*,Left_*, ⌠- other numbered/indexable children
- tag hierarchies:
Interactable_Door,Interactable_Button
Without pattern matching support, we must resort to using QueryDescendants but also scanning the result with our string matching logic. No native support for this is poor for entity-component-like systems and generally any structured hierarchy in the DataModel.
I also mentioned tags because the concept of being able to query tags has been requested over the years. Currently, I would find it useful for a system where I have a specific tag on an instance (ex: entity-123), but I also must have an Entity tag on the instance so I can get all instances that are considered entities. Being able to query tags would have it so I only need the first tag and less error-prone since I no longer rely on both tags existing.
QueryService
Based on the limitations, I propose the addition of a QueryService service. The existence and API of this service promotes simplicity, clarity, and a one location for commonly needed behaviors related to queries. Below are listed the current ideas for the initial behaviors.
Note: All APIs listed are suggestions, not demands. The goal is to provide the idea of how these behaviors may be implemented.
local QueryService = game:GetService('QueryService')
-- the possible methods below are methods of this service
Syntax
QueryService uses a global syntax that supports pattern matching, logical combinators, and the existing , combinator. Selectors and some combinators depend on the domain in which the query is evaluated. The , combinator can be used for any domain method that returns a list of results. The backslash (\) can be used to escape any special character in this syntax.
All domains support string pattern matching which uses standard string.match rules, but patterns must be wrapped in curly brackets ({}) to avoid ambiguity with literal values or special characters.
All domains support logical operators which include !, &, and |. Operate precendence follows conventional rules: highest for !, then &, and finally |. Parentheses (()) may be used to group expressions for clarity or correctness.
For the DataModel domain, the existing > (child) and >> (descendant) combinators remain supported. This can also use familiar selector types such as ClassName, #Name, .Tag, [prop=value], [$attr=value]. Effectively, DataModel queries have at least the same syntax as current QueryDescendants does. However, the overall syntax supports what is listed above as well.
The Tag domain does not use any extra combinators and only selects plaintext (with pattern matching support).
Note: I have not verified that my suggested syntax avoids all ambiguity. More consideration may be needed.
Domain: DataModel
The current DataModel querying workflow is fragmented. QueryDescendants only returns the present state and doesnât help on its own when content is streamed in or when attributes/tags change afterward. When considering how I might use QueryDescendants in my own projects I immediately ran into these issues as I often code reactively for streamed content.
Examples
- Needing to detect when an instance with a naming convention appears (ex:
Item_*) - Waiting for a descendant with some classification to be streamed in under some parent
- Attaching behavior to instances matching a query as and when they exist
- Lifecycle management for filtered instances when they no longer match the query, are destroyed, or streamed out
- Handling dynamic hierarchies where properties, attributes, or tags change after the instance already exists
Possible API
-- the string is the query string
-- the observer callback returns a function which is called when the instance no longer matches the query or it was destroyed or streamed out (common pattern in development for cleanup)
:Compare(Instance, string) : boolean
:HasDescendant(Instance, string) : boolean
:GetDescendant(Instance, string) : Instance?
:GetDescendants(Instance, string) : array<Instance>
:WaitDescendant(Instance, string, timeout: number?) : Instance?
:OnDescendantAdded(Instance, string, (descendant: Instance) -> ()) : RBXScriptConnection
:OnDescendantRemoved(Instance, string, (descendant: Instance) -> ()) : RBXScriptConnection
:ObserveDescendants(Instance, string, (descendant: Instance) -> (() -> ()) : RBXScriptConnection
:HasAncestor(Instance, string) : boolean
:GetAncestor(Instance, string) : Instance?
:GetAncestors(Instance, string) : array<Instance>
:WaitAncestor(Instance, string, timeout: number?) Instance?
:OnAncestorAdded(Instance, string, (ancestor: Instance) -> ()) : RBXScriptConnection
:OnAncestorRemoved(Instance, string, (ancestor: Instance) -> ()) : RBXScriptConnection
:ObserveAncestors(Instance, string, (ancestor: Instance) -> (() -> ()) : RBXScriptConnection
Domain: Tags
CollectionService only supports exact tag matches. In my own projects (and in many community patterns), tags are often organized into classifications. For example, there might be generic behavior we want to apply to enemies, but then specific behavior for kinds of entities. I described a similar issue before where I would have something like enemy_skeleton tag, but also each of those (and other classifications) would need a generic enemy tag. With QueryService we would be able to do something like :OnTagAdded('{enemy_%a+}', function(tag, inst) ... end) for reactive behavior.
Possible API
-- the string is the query string
-- the observer callback returns a function which is called when the instance no longer matches the query or it was destroyed or streamed out (common pattern in development for cleanup)
:HasTag(Instance, string) : boolean
:GetTags(string) : array<string>
:GetTagged(string) : array<Instance>
:WaitTag(string, timeout: number?) : string?
:WaitTagged(string, timeout: number?) : Instance?
:OnTagAdded(string, (tag: string, Instance) -> ()) : RBXScriptConnection
:OnTagRemoved(string, (tag: string, Instance) -> ()) : RBXScriptConnection
:ObserveTags(string, (tag: string, Instance) -> (() -> ())) : RBXScriptConnection
Conclusion
The best part about a QueryService is we can scale it to other domains without conflict. For example, there is potential for this API to be extended to attributes where the query would operate directly on attributes across the DataModel.
Querying and reacting to instances across hierarchy, tags, and naming conventions is currently fragmented across several APIs. The workarounds are not ideal and can lead to hard to manage and error-prone code. A QueryService with a simple API, unified syntax, reactive observers and listeners, pattern matching, and more would make workflows around queries much simpler and more maintainable. It consolidates common behaviors into a single provider that scales cleanly as a projects grows, improves code clarity, and reduces the need for extra custom infrastructure.
If these problems and suggestions were to be addressed it would solve 99% of the logical fragmentation and boilerplate around querying.