· 6 years ago · Apr 08, 2020, 08:06 PM
1-- Data
2-- Stephen Leitnick
3-- November 20, 2018
4
5
6--[[
7
8 CONSTRUCTORS:
9 data = Data.new(name, scope [, ordered]) [Creates or gets existing Data object for given name and scope]
10 data = Data.ForPlayer(userId | player [, ordered]) [Creates or gets existing Data object for given player OR UserId]
11 data = Data.ForServer([ordered]) [Creates or gets existing Data object for the server]
12
13 STATIC FIELDS:
14 Data.IsUsingMockService [Whether or not the MockDataStoreService is being utilized]
15 Data.AutoSaveInterval [How often all data auto-saves. Defaults to '60' seconds]
16 Data.PlayerLeftSaveInterval [How long to wait after a player leaves before saving all data. Defaults to '10' seconds]
17 Data.SaveInStudio [Defaults to 'false'; indicates if data should save when testing in Studio]
18
19 STATIC METHODS:
20 Data:OnClose(onCloseFunc) [Guaranteed to be executed before all data is saved when server is closing]
21
22 FIELDS:
23 data.CanAutoSave [Defaults to 'true']
24 data.DestroyOnLeave [Defaults to 'true'; if linked to a player, 'Destroy()' will automatically be invoked when the player leaves; see static field PlayerLeftSaveInterval]
25
26 METHODS:
27 Promise<Value> data:Get(String key [, Any defaultValue])
28 Promise<DataStorePage> data:GetSorted(Boolean isAscendint, Int pageSize, Number minValue, Number maxValue)
29 Promise<Void> data:Set(String key, String value)
30 Promise<Void> data:Delete(String key)
31 Promise<Value> data:Increment(String key, Number incrementValue)
32 Promise<Connection> data:OnUpdate(String key, Function callback)
33 Promise<Void> data:Save(String key)
34 Promise<Void> data:SaveAll()
35 Promise<Void> data:Destroy([Boolean saveAll])
36 Number data:GetRequestBudget(DataStoreRequestType requestType)
37 Void data:MarkDirty(String key)
38
39 EVENTS:
40 data.Success data.Success:Connect(String method, String key)
41 data.Failed data.Failed:Connect(String method, String key, String err)
42
43 METHOD DESCRIPTIONS:
44 Get
45 Gets/loads the value from the key. The optional 'defaultValue' can be used
46 if the retrieved value from the DataStore is nil. This method will only
47 call the DataStore if it has not yet done so and gotten a non-nil value. If
48 a call has already been made for the given key, the value will be cached
49 and the cached value will be returned.
50
51 GetSorted
52 Calls the GetSortedAsync method on the OrderedDataStore connected to this
53 data object. It will return a custom DataStorePage object described below:
54
55 DataStorePage:
56 Boolean dataStorePage.IsFinished
57 Promise<Void> dataStorePage:AdvanceToNextPage()
58 Promise<Table> dataStorePage:GetCurrentPage()
59
60 Set
61 Sets the value to the given key in the local cache. This does NOT set the
62 value in the DataStore. Call 'Save' or 'SaveAll' to explicitly save to the
63 DataStore. Otherwise, the key will automatically save during the auto-save
64 period, when the player leaves, or when the server shuts down.
65
66 If you try to set a value to a key that has not yet been cached, it will
67 first try to call the DataStore to ensure it is working. If DataStores
68 are down, this call will fail, ensuring that you don't start overriding
69 values during DataStore downtime.
70
71 Delete
72 This deletes the value from the cache AND the DataStore. This is the same
73 as calling 'data:Set("key", nil)' but is preferred for its explicit naming.
74
75 Increment
76 This increments a value on a given key. If the current value doesn't exist,
77 then it will assume a starting value of 0. This will fail if the increment
78 or the existing value is not a number.
79
80 OnUpdate
81 This registers a function to listen for changes on a key at the DataStore
82 level, NOT the cache level. Thus, using 'data:Set()' won't trigger a bound
83 function on OnUpdate. In other words, this function can be used to tell
84 when a key has been saved onto the DataStore.
85
86 Save
87 Saves a cached key to the DataStore. The key must currently have a cached
88 value, otherwise this request will fail.
89
90 SaveAll
91 Saves all currently cached keys to the DataStore.
92
93 Destroy
94 Destroys the data object instance. If 'saveAll' is set to 'true', this will
95 also call 'SaveAll' before removing any of the data.
96
97 GetRequestBudget
98 This is exactly the same as the DataStoreService's GetRequestBudget. Read
99 the documentation on the Roblox Developer site:
100 https://developer.roblox.com/api-reference/function/DataStoreService/GetRequestBudgetForRequestType
101
102 MarkDirty
103 Marks the key as dirty, which means that it will be forced to save the
104 next time a save invocation occurs. This is necessary when making changes
105 to tables.
106
107
108 EXAMPLES:
109
110 data = Data.ForPlayer(somePlayer)
111
112 -- Using 'Await' to get money:
113 local success, money = data:Get("money", 0):Await()
114 if (success) then
115 print("Money", money)
116 else
117 warn("Failed to get money", money)
118 end
119
120 -- Using 'Then' to get money:
121 data:Get("money", 0):Then(function(money)
122 print("Money", money)
123 end, function(err)
124 warn("Failed to get money", err)
125 end)
126
127 -- Setting money:
128 data:Set("money", 25):Await()
129
130 -- Saving:
131 data:Save("money"):Then(function()
132 print("Successfully saved money")
133 end):Catch(function(err)
134 warn("Failed to save money", err)
135 end):Finally(function()
136 -- Cleanup stuff
137 end)
138
139
140 -- OrderedDataStore example:
141 data = Data.ForPlayer(somePlayer, true)
142
143 data:GetSorted(true, 10, 0, 1000):Then(function(pages)
144 return pages:GetCurrentPage()
145 end):Then(function(page)
146 for k,v in pairs(page) do
147 print(k, v)
148 end
149 end)
150
151
152
153 For in-depth info on DataStores:
154
155 https://devforum.roblox.com/t/details-on-datastoreservice-for-advanced-developers/175804
156
157--]]
158
159
160
161local Data = {}
162Data.__index = Data
163Data._onCloseHandlers = {}
164
165-- Static fields; customize as needed:
166Data.AutoSaveInterval = 60
167Data.PlayerLeftSaveInterval = 10
168Data.SaveInStudio = false
169Data.Log = false
170
171-- Constants based on internal Roblox DataStore; DO NOT CHANGE:
172local NAME_MAX_LEN = 50
173local SCOPE_MAX_LEN = 50
174local KEY_MAX_LEN = 49
175
176local KEY_MAX_LEN_ERR = "Key must be a string less or equal to " .. KEY_MAX_LEN .. " characters"
177local PLAYER_DATA_NAME = "PlayerData"
178
179local dataStoreService = game:GetService("DataStoreService")
180Data.IsUsingMockService = false
181
182local dataPool = {}
183local assert = assert
184local tableUtil
185local Promise
186
187
188local function Log(...)
189 if (not Data.Log) then return end
190 print("Data ->", ...)
191end
192
193
194local function HeartbeatSpawn(callback, ...)
195 local hb
196 local args = table.pack(...)
197 hb = game:GetService("RunService").Heartbeat:Connect(function()
198 hb:Disconnect()
199 callback(table.unpack(args, 1, args.n))
200 end)
201end
202
203
204-- Check if key matches DataStore criteria:
205local function CheckKey(key)
206 return (type(key) == "string" and #key <= KEY_MAX_LEN)
207end
208
209-- Retrieve cached DataStore from name and scope:
210local function GetDataFromNameAndScope(name, scope)
211 local ds = dataStoreService:GetDataStore(name, scope)
212 return dataPool[ds]
213end
214
215
216---------------------------------------------------------------------------------------------------------------------------
217-- DataStorePages wrapper for promises:
218
219local DataStorePages = {}
220DataStorePages.__index = DataStorePages
221function DataStorePages.new(dsp)
222 return setmetatable({
223 DSP = dsp;
224 IsFinished = dsp.IsFinished;
225 }, DataStorePages)
226end
227
228function DataStorePages:AdvanceToNextPage()
229 return Promise.Async(function(resolve, reject)
230 local success, err = pcall(self.DSP.AdvanceToNextPageAsync, self.DSP)
231 self.IsFinished = self.DSP.IsFinished
232 if (success) then resolve() else reject(err) end
233 end)
234end
235
236function DataStorePages:GetCurrentPage()
237 return Promise.Async(function(resolve, reject)
238 local success, page = pcall(self.DSP.GetCurrentPage, self.DSP)
239 if (success) then resolve(page) else reject(page) end
240 end)
241end
242---------------------------------------------------------------------------------------------------------------------------
243
244
245---------------------------------------------------------------------------------------------------------------------------
246-- CONSTRUCTORS:
247
248
249function Data.new(name, scope, ordered)
250
251 -- Check arguments:
252 assert(type(name) == "string", "Argument #1 (name) must be a string")
253 assert(type(scope) == "string", "Argument #2 (scope) must be a string")
254 assert(#name <= NAME_MAX_LEN, "Argument #1 (name) must be less or equal to " .. NAME_MAX_LEN .. " characters")
255 assert(#scope <= SCOPE_MAX_LEN, "Argument #1 (scope) must be less or equal to " .. SCOPE_MAX_LEN .. " characters")
256 assert(type(ordered) == "boolean" or ordered == nil, "Argument #3 (ordered) must be a boolean or nil")
257
258 ordered = (not not ordered)
259
260 -- Get cached 'data' object if available:
261 local ds = (ordered and dataStoreService:GetOrderedDataStore(name, scope) or dataStoreService:GetDataStore(name, scope))
262 local self = dataPool[ds]
263 if (self and not self._destroyed) then return self end
264
265 -- Create new 'data' object:
266 self = setmetatable({
267 Name = name;
268 Scope = scope;
269 CanAutoSave = true;
270 DestroyOnLeave = true;
271 _ds = ds;
272 _cache = {};
273 _dirty = {};
274 _ordered = ordered;
275 _destroyed = false;
276 _destroying = false;
277 }, Data)
278
279 -- Data events:
280 self.Success = self.Shared.Event.new()
281 self.Failed = self.Shared.Event.new()
282
283 dataPool[ds] = self
284
285 Log("Created new Data object:", tostring(self))
286
287 return self
288
289end
290
291
292function Data.ForPlayer(userId, ordered)
293 if (typeof(userId) == "Instance") then
294 -- Capture UserId from the player object:
295 assert(userId:IsA("Player"), "Expected Player; got " .. userId.ClassName)
296 userId = userId.UserId
297 else
298 assert(type(userId) == "number" and userId >= 0 and math.floor(userId) == userId, "Expected integer >= 0")
299 end
300 local scope = tostring(userId)
301 local data = Data.new(PLAYER_DATA_NAME, scope, ordered)
302 return data
303end
304
305
306function Data.ForServer(ordered)
307 return Data.new("global", "global", ordered)
308end
309
310
311---------------------------------------------------------------------------------------------------------------------------
312-- PRIVATE METHODS:
313
314
315-- Load a given key from the DataStore:
316function Data:_load(key)
317 Log("Loading " .. key .. "...")
318 return Promise.Async(function(resolve, reject)
319 -- Call GetAsync and cache the results:
320 local success, value = pcall(self._ds.GetAsync, self._ds, key)
321 if (success) then
322 Log("Succesfully loaded key " .. key .. ":", value)
323 self._cache[key] = value
324 self._dirty[key] = false
325 self.Success:Fire("GetAsync", key)
326 resolve(value)
327 else
328 Log("Failede to load key " .. key)
329 self.Failed:Fire("GetAsync", key, value)
330 reject(value)
331 end
332 end)
333end
334
335
336-- Get the cached value from the key, or load it from the DataStore if not yet cached:
337function Data:_loadIfNotCached(key)
338 if (self:_cacheExists(key)) then
339 return Promise.Resolve(self:_getCache(key))
340 end
341 return self:_load(key)
342end
343
344
345-- Save the key/value to the DataStore:
346function Data:_save(key, value)
347 Log("Saving " .. key .. "...")
348 if (self._dirty[key] == false) then
349 -- If not dirty, the given key does not need to be saved:
350 Log("No save necessary; " .. key .. " not marked as dirty")
351 return Promise.Resolve()
352 end
353 return Promise.Async(function(resolve, reject)
354 -- Call SetAsync and mark key as no longer dirty:
355 local valBeforeSave = self:_getCache(key)
356 local success, err = pcall(self._ds.SetAsync, self._ds, key, value)
357 if (success) then
358 if (self:_getCache(key) == valBeforeSave) then
359 self._dirty[key] = false
360 end
361 self.Success:Fire("SetAsync", key)
362 Log("Successfully saved " .. key .. ":", value)
363 resolve()
364 else
365 Log("Failed to save " .. key)
366 self.Failed:Fire("SetAsync", key, err)
367 reject(err)
368 end
369 end)
370end
371
372
373function Data:_delete(key)
374 Log("Deleting " .. key .. "...")
375 return Promise.Async(function(resolve, reject)
376 -- Call RemoveAsync and remove value from cache:
377 local success, err = pcall(self._ds.RemoveAsync, self._ds, key)
378 if (success) then
379 Log("Successfully deleted key " .. key)
380 self:_clearCache(key)
381 self.Success:Fire("RemoveAsync", key)
382 resolve()
383 else
384 Log("Failed to delete key " .. key)
385 self.Failed:Fire("RemoveAsync", key, err)
386 reject(err)
387 end
388 end)
389end
390
391
392function Data:_update(key, transformFunc)
393 Log("Updating " .. key .. "...")
394 return Promise.Async(function(resolve, reject)
395 -- Call UpdateAsync and update cache with returned value:
396 local success, value = pcall(self._ds.UpdateAsync, self._ds, key, transformFunc)
397 if (success) then
398 Log("Successfully updated key " .. key)
399 self:_setCache(key, value, true)
400 self.Success:Fire("UpdateAsync", key)
401 resolve(value)
402 else
403 Log("Failed to update key " .. key)
404 self.Failed:Fire("UpdateAsync", key, value)
405 reject(value)
406 end
407 end)
408end
409
410
411function Data:_getSorted(isAscending, pageSize, minValue, maxValue)
412 return Promise.Async(function(resolve, reject)
413 -- Call GetSortedAsync and return the custom DataStorePages object:
414 local success, dsp = pcall(self._ds.GetSortedAsync, self._ds, isAscending, pageSize, minValue, maxValue)
415 if (success) then
416 resolve(DataStorePages.new(dsp))
417 else
418 reject(dsp)
419 end
420 end)
421end
422
423
424-- Retrieve a cached value for the given key:
425function Data:_getCache(key)
426 return self._cache[key]
427end
428
429
430-- Set an item in the cache and mark clean or dirty:
431function Data:_setCache(key, value, isClean)
432 if (self._cache[key] ~= value) then
433 self._cache[key] = value
434 self._dirty[key] = (not isClean)
435 end
436end
437
438
439-- Delete an item from the cache:
440function Data:_clearCache(key)
441 self._cache[key] = nil
442 self._dirty[key] = nil
443end
444
445
446-- Check if a key has a value in the cache:
447function Data:_cacheExists(key)
448 return (self._cache[key] ~= nil)
449end
450
451
452-- Get the number of keys within the cache:
453function Data:_countKeysInCache()
454 local keyCount = 0
455 for _ in pairs(self._cache) do
456 keyCount = (keyCount + 1)
457 end
458 return keyCount
459end
460
461
462---------------------------------------------------------------------------------------------------------------------------
463-- PUBLIC METHODS:
464
465
466function Data:GetRequestBudget(reqType)
467 -- See:
468 -- https://developer.roblox.com/api-reference/function/DataStoreService/GetRequestBudgetForRequestType
469 -- https://developer.roblox.com/api-reference/enum/DataStoreRequestType
470 -- https://developer.roblox.com/articles/Datastore-Errors
471 return dataStoreService:GetRequestBudgetForRequestType(reqType)
472end
473
474
475function Data:Get(key, defaultVal)
476 if (self._destroyed) then
477 return Promise.Reject("Data already destroyed")
478 end
479 if (not CheckKey(key)) then
480 return Promise.Reject(KEY_MAX_LEN_ERR)
481 end
482 if (self:_cacheExists(key)) then
483 -- Return the cached value:
484 return Promise.Resolve(self:_getCache(key))
485 end
486 -- Load and return value since it was not in the cache:
487 return self:_load(key):Then(function(value)
488 if (value == nil and defaultVal ~= nil) then
489 value = (typeof(defaultVal) ~= "table" and defaultVal or tableUtil.Copy(defaultVal))
490 return self:Set(key, value):Then(function()
491 return value
492 end)
493 else
494 return value
495 end
496 end)
497end
498
499
500function Data:GetSorted(isAscending, pageSize, minValue, maxValue)
501 if (self._destroyed) then
502 return Promise.Reject("Data already destroyed")
503 end
504 if (not self._ordered) then
505 return Promise.Reject("GetSorted can only be invoked on an ordered data object")
506 end
507 return self:_getSorted(isAscending, pageSize, minValue, maxValue)
508end
509
510
511function Data:Set(key, value)
512 if (self._destroyed) then
513 return Promise.Reject("Data already destroyed")
514 end
515 if (not CheckKey(key)) then
516 return Promise.Reject(KEY_MAX_LEN_ERR)
517 end
518 return self:_loadIfNotCached(key):Then(function()
519 if (value == nil) then
520 return self:_delete(key)
521 else
522 self:_setCache(key, value)
523 end
524 end)
525end
526
527
528function Data:Increment(key, increment)
529 if (self._destroyed) then
530 return Promise.Reject("Data already destroyed")
531 end
532 if (type(increment) ~= "number") then
533 return Promise.Reject("Increment must be a number")
534 end
535 -- Get the current value, increment it, then set the new value:
536 return self:Get(key, 0):Then(function(value)
537 if (type(value) ~= "number") then
538 error("Cannot increment a non-number value")
539 return
540 end
541 value = (value + increment)
542 return self:Set(key, value):Then(function()
543 return value
544 end)
545 end)
546end
547
548
549function Data:Delete(key)
550 return self:Set(key, nil)
551end
552
553
554function Data:OnUpdate(key, callback)
555 if (self._destroyed) then
556 return Promise.Reject("Data already destroyed")
557 end
558 if (not CheckKey(key)) then
559 return Promise.Reject(KEY_MAX_LEN_ERR)
560 end
561 if (type(callback) ~= "function") then
562 return Promise.Reject("Callback must be a function")
563 end
564 return Promise.Async(function(resolve, reject)
565 local success, err = pcall(self._ds.OnUpdate, self._ds, key, callback)
566 if (success) then
567 self.Success:Fire("OnUpdate", key)
568 resolve()
569 else
570 self.Failed:Fire("OnUpdate", key, err)
571 reject(err)
572 end
573 end)
574end
575
576
577function Data:Save(key)
578 if (self._destroyed) then
579 return Promise.Reject("Data already destroyed")
580 end
581 if (not CheckKey(key)) then
582 return Promise.Reject(KEY_MAX_LEN_ERR)
583 end
584 local cachedVal = self:_getCache(key)
585 if (cachedVal == nil) then
586 return Promise.Reject("Cannot save key that has not already been loaded via Data:Get(key)")
587 end
588 return self:_save(key, cachedVal)
589end
590
591
592function Data:SaveAll()
593 if (self._destroyed) then
594 return Promise.Reject("Data already destroyed")
595 end
596 -- Collect all 'Save' promises and return them all in a single promise:
597 local promises = {}
598 for key in pairs(self._cache) do
599 promises[#promises + 1] = self:Save(key)
600 end
601 return Promise.All(promises)
602end
603
604
605function Data:Update(key, transformFunc)
606 if (self._destroyed) then
607 return Promise.Reject("Data already destroyed")
608 end
609 if (not CheckKey(key)) then
610 return Promise.Reject(KEY_MAX_LEN_ERR)
611 end
612 if (type(transformFunc) ~= "function") then
613 return Promise.Reject("TransformFunction must be a function")
614 end
615 return self:_update(key, transformFunc)
616end
617
618
619function Data:MarkDirty(key)
620 self._dirty[key] = true
621end
622
623
624function Data:Destroy(save)
625 Log("Destroying data object:", tostring(self))
626 if (self._destroyed or self._destroying) then
627 return Promise.Reject("Data already destroyed")
628 end
629 self._destroying = true
630 local savePromise
631 if (save) then
632 savePromise = self:SaveAll(false, nil)
633 else
634 savePromise = Promise.Resolve()
635 end
636 return savePromise:Then(function()
637 -- Clear and destroy objects:
638 Log("Data successfully destroyed")
639 self._destroyed = true
640 self._cache = {}
641 self._dirty = {}
642 self.Failed:Destroy()
643 self.Success:Destroy()
644 dataPool[self._ds] = nil
645 end):Catch(function(err)
646 -- Failed to destroy, thus remark as not destroyed & rethrow error:
647 Log(" Saving failed")
648 self._destroying = false
649 error(err)
650 end)
651end
652
653
654---------------------------------------------------------------------------------------------------------------------------
655
656
657function Data:OnClose(handler)
658 assert(type(handler) == "function", "OnClose handler must be a function")
659 for _,h in pairs(self._onCloseHandlers) do
660 if (h == handler) then
661 error("Handler already binded")
662 end
663 end
664 table.insert(self._onCloseHandlers, handler)
665end
666
667
668function Data:Start()
669
670 local gameClosing = false
671 local autoSaving = false
672
673 if (game.GameId == 0) then
674 Data.IsUsingMockService = true
675 elseif (game:GetService("RunService"):IsStudio()) then
676 if (not Data.SaveInStudio) then
677 Data.IsUsingMockService = true
678 else
679 -- Verify status of the DataStoreService on startup:
680 local success, err = pcall(function()
681 dataStoreService:GetDataStore("__aero"):UpdateAsync("dss_api_check", function(v) return v == nil and true or v end)
682 end)
683 if (not success) then
684 -- Error codes: https://developer.roblox.com/articles/Datastore-Errors
685 local errCode = tonumber(err:match("^%d+"))
686 if (errCode == 502 or errCode == 403) then
687 Data.IsUsingMockService = true
688 elseif (errCode == 304) then
689 error("DataStoreService API check failed on UpdateAsync (request queue full)")
690 else
691 error("DataStoreService API error " .. errCode or "[Unknown Status]" .. ": " .. err)
692 end
693 end
694 end
695 end
696
697 local function FireBoundToCloseCallbacks()
698 local numBinded = #self._onCloseHandlers
699 if (numBinded == 0) then return end
700 local bindable = Instance.new("BindableEvent")
701 local numCompleted = 0
702 for _,func in pairs(self._onCloseHandlers) do
703 HeartbeatSpawn(function()
704 local success, err = pcall(func)
705 if (not success) then
706 warn("Data BindToClose function failed: " .. tostring(err))
707 end
708 numCompleted = (numCompleted + 1)
709 if (numCompleted == numBinded) then
710 bindable:Fire()
711 end
712 end)
713 end
714 bindable.Event:Wait()
715 bindable:Destroy()
716 end
717
718 local function AutoSaveAllData()
719 if (autoSaving) then return end
720 autoSaving = true
721 local promises = {}
722 for _,data in pairs(dataPool) do
723 if (data.CanAutoSave) then
724 --local budget = dataStoreService:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.SetIncrementAsync)
725 --local keys = data:_countKeysInCache()
726 local saveAllPromise = data:SaveAll()
727 if (not gameClosing) then
728 saveAllPromise:Await()
729 else
730 promises[#promises + 1] = saveAllPromise
731 end
732 end
733 end
734 if (#promises == 0) then
735 autoSaving = false
736 else
737 Promise.All(promises):Await()
738 autoSaving = false
739 end
740 end
741
742 if (self.IsUsingMockService) then
743 -- Use mock DataStoreService:
744 dataStoreService = require(script.MockDataStoreService)
745 else
746 -- Auto-save all data before server closes:
747 game:BindToClose(function()
748 gameClosing = true
749 FireBoundToCloseCallbacks()
750 AutoSaveAllData()
751 end)
752 end
753
754 -- Destroy player data when player leaves:
755 game:GetService("Players").PlayerRemoving:Connect(function(player)
756 local data = GetDataFromNameAndScope(PLAYER_DATA_NAME, tostring(player.UserId))
757 if (not data) then return end
758 wait(self.PlayerLeftSaveInterval)
759 if (gameClosing or not data.DestroyOnLeave) then return end
760 data:Destroy(true)
761 end)
762
763 -- Auto-save cycle:
764 HeartbeatSpawn(function()
765 while (true) do
766 wait(self.AutoSaveInterval)
767 if (gameClosing) then break end
768 AutoSaveAllData()
769 end
770 end)
771
772end
773
774
775function Data:Init()
776 Promise = self.Shared.Promise
777 tableUtil = self.Shared.TableUtil
778end
779
780
781function Data:__tostring()
782 return ("Data (Name=%s, Scope=%s, Ordered=%s)"):format(self.Name, self.Scope, self._ordered and "Yes" or "No")
783end
784
785
786return Data