From c371bd89c56165ec6a2c565226c45ad1e8ef10ff Mon Sep 17 00:00:00 2001 From: Anton Tarasenko Date: Thu, 8 Apr 2021 17:42:40 +0700 Subject: [PATCH] Add JSON pointers support for Acedia `Collection`s --- sources/Data/Collections/AssociativeArray.uc | 15 +- sources/Data/Collections/Collection.uc | 386 +++++++++++++++++- sources/Data/Collections/DynamicArray.uc | 17 +- .../Tests/TEST_CollectionsMixed.uc | 105 +++++ sources/Manifest.uc | 9 +- 5 files changed, 525 insertions(+), 7 deletions(-) create mode 100644 sources/Data/Collections/Tests/TEST_CollectionsMixed.uc diff --git a/sources/Data/Collections/AssociativeArray.uc b/sources/Data/Collections/AssociativeArray.uc index 724232c..cefab76 100644 --- a/sources/Data/Collections/AssociativeArray.uc +++ b/sources/Data/Collections/AssociativeArray.uc @@ -6,7 +6,7 @@ * stores generic `AcediaObject` keys and values. `Text` can be used instead of * typical `string` keys and primitive values can be added in their boxed form * (either as actual `Box` or as it's reference counterpart). - * Copyright 2020 Anton Tarasenko + * Copyright 2020 - 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -530,6 +530,19 @@ public final function Entry GetEntryByIndex(Index index) return hashTable[index.bucketIndex].entries[index.entryIndex]; } +protected function AcediaObject GetByText(MutableText key) +{ + local Text immutableKey; + local AcediaObject result; + if (key == none) { + return none; + } + immutableKey = key.Copy(); + result = GetItem(immutableKey); + immutableKey.FreeSelf(); + return result; +} + defaultproperties { iteratorClass = class'AssociativeArrayIterator' diff --git a/sources/Data/Collections/Collection.uc b/sources/Data/Collections/Collection.uc index 6a149bb..5496378 100644 --- a/sources/Data/Collections/Collection.uc +++ b/sources/Data/Collections/Collection.uc @@ -2,7 +2,7 @@ * Acedia provides a small set of collections for easier data storage. * This is their base class that provides a simple interface for * common methods. - * Copyright 2020 Anton Tarasenko + * Copyright 2020 - 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -22,8 +22,41 @@ class Collection extends AcediaObject abstract; +// A private struct for `Collection` that disassembles a +// [JSON pointer](https://tools.ietf.org/html/rfc6901) into the path parts, +// separated by "/". +// It is used to simplify the code working with them. +struct JSONPointer +{ + // Records whether JSON pointer had it's escape sequences ("~0" and "~1"); + // This is used to determine if we need to waste our time replacing them. + var private bool hasEscapedSequences; + // Parts of the path that were separated by "/" character. + var private array keys; + // Points at a part in `keys` to be used next. + var private int nextIndex; +}; + var class iteratorClass; +var protected const int TSLASH, TJSON_ESCAPE, TJSON_ESCAPED_SLASH; +var protected const int TJSON_ESCAPED_ESCAPE; + +/** + * Method that must be overloaded for `GetItemByPointer()` to properly work. + * + * This method must return an item that `key` refers to with it's + * textual content (not as an object itself). + * For example, `DynamicArray` parses it into unsigned number, while + * `AssociativeArray` converts it into an immutable `Text` key, whose hash code + * depends on the contents. + * + * There is no requirement that all stored values must be reachable by + * this method (i.e. `AssociativeArray` only lets you access values with + * `Text` keys). + */ +protected function AcediaObject GetByText(MutableText key); + /** * Creates an `Iterator` instance to iterate over stored items. * @@ -46,6 +79,357 @@ public final function Iter Iterate() return newIterator; } +// Created `JSONPointer` structure (inside `ptr` out argument), based on +// it's textual representation `pointerAsText`. Returns whether it's succeeded. +// Deviates from JSON pointer specification in also allowing non-empty +// arguments not starting with "/" by treating them as a whole variable name. +private final function bool MakePointer(Text pointerAsText, out JSONPointer ptr) +{ + if (pointerAsText == none) { + return false; + } + FreePointer(ptr); // Clean up, in case we were given used pointer + ptr.hasEscapedSequences = (pointerAsText.IndexOf(T(TJSON_ESCAPE)) >= 0); + if (!pointerAsText.StartsWith(T(TSLASH))) + { + ptr.nextIndex = 0; + ptr.keys[0] = pointerAsText.MutableCopy(); + return true; + } + + ptr.keys = pointerAsText.SplitByCharacter(T(TSLASH).GetCharacter(0)); + // First elements of the array will be empty, so throw it away + _.memory.Free(ptr.keys[0]); + ptr.nextIndex = 1; + return true; +} + +private final function bool IsFinalPointerKey(JSONPointer ptr) +{ + return ((ptr.nextIndex + 1) == ptr.keys.length); +} + +private final function MutableText PopJSONKey(out JSONPointer ptr) +{ + local MutableText result; + if (ptr.nextIndex >= ptr.keys.length) { + return none; + } + ptr.nextIndex += 1; + result = ptr.keys[ptr.nextIndex - 1]; + if (ptr.hasEscapedSequences) + { + // Order is specific, necessity of which is explained in + // JSON Pointer's documentation: + // https://tools.ietf.org/html/rfc6901 + result.Replace(T(TJSON_ESCAPED_SLASH), T(TSLASH)); + result.Replace(T(TJSON_ESCAPED_ESCAPE), T(TJSON_ESCAPE)); + } + return result; +} + +// Frees all memory used up by the `JSONPointer` +private final function FreePointer(out JSONPointer ptr) +{ + _.memory.FreeMany(ptr.keys); +} + +/** + * Returns stored `AcediaObject` from the caller storage + * (or from it's sub-storages) via given `Text` path. + * + * Path is used in one of the two ways: + * 1. If path is an empty `Text` or if it starts with "/" character, + * it will be interpreted as + * a [JSON pointer](https://tools.ietf.org/html/rfc6901); + * 2. Otherwise it will be used as an argument's name. + * + * Acedia provides two collections: + * 1. `DynamicArray` is treated as a JSON array in the context of + * JSON pointers and passed variable names are treated as a `Text` + * representation of it's integer indices; + * 2. `AssociativeArray` is treated as a JSON object in the context of + * JSON pointers and passed variable names are treated as it's + * `Text` keys (to refer to an element with an empty key, use "/", + * since "" is treated as a JSON pointer and refers to + * the array itself). + * It is also possible to define your own collection type that will also be + * integrated with this method by making it a sub-class of `Collection` and + * appropriately defining `GetByText()` protected method. + * + * Making only getter available (without setters or `Take...()` methods that + * also remove returned element) is a deliberate choice made to reduce amount + * of possible errors when working with collections. + * + * There is no requirement that all stored values must be reachable by + * this method (i.e. `AssociativeArray` only lets you access values with + * `Text` keys). + * + * @param jsonPointerAsText Treated as a JSON pointer if it starts with "/" + * character or is an empty `Text`, otherwise treated as an item's + * name / identificator inside the caller collection. + * @return An item `jsonPointerAsText` is referring to (according to the above + * stated rules). `none` if such item does not exist. + */ +public final function AcediaObject GetItemByPointer(Text jsonPointerAsText) +{ + local AcediaObject result; + local JSONPointer ptr; + local Collection nextCollection; + if (jsonPointerAsText == none) return none; + if (jsonPointerAsText.IsEmpty()) return self; + + if (!MakePointer(jsonPointerAsText, ptr)) { + return none; + } + nextCollection = self; + while (!IsFinalPointerKey(ptr)) + { + nextCollection = Collection(nextCollection.GetByText(PopJSONKey(ptr))); + if (nextCollection == none) + { + FreePointer(ptr); + return none; + } + } + result = nextCollection.GetByText(PopJSONKey(ptr)); + FreePointer(ptr); + return result; +} + +/** + * Returns a `bool` value stored (in the caller `Collection` or + * one of it's sub-collections) pointed by + * [JSON pointer](https://tools.ietf.org/html/rfc6901). + * See `GetItemByPointer()` for more information. + * + * Referred value must be stored as `BoolBox` or `BoolRef` + * (or one of their sub-classes) for this method to work. + * + * @param jsonPointerAsText Description of a path to the `bool` value. + * @param defaultValue Value to return in case `jsonPointerAsText` + * does not point at any existing value or if that value does not have + * appropriate type. + * @return `bool` value, stored at `jsonPointerAsText` or `defaultValue` if it + * is missing or has a different type. + */ +public final function bool GetBoolByPointer( + Text jsonPointerAsText, + optional bool defaultValue) +{ + local AcediaObject result; + local BoolBox asBox; + local BoolRef asRef; + result = GetItemByPointer(jsonPointerAsText); + if (result == none) { + return defaultValue; + } + asBox = BoolBox(result); + if (asBox != none) { + return asBox.Get(); + } + asRef = BoolRef(result); + if (asRef != none) { + return asRef.Get(); + } + return defaultValue; +} + +/** + * Returns a `byte` value stored (in the caller `Collection` or + * one of it's sub-collections) pointed by + * [JSON pointer](https://tools.ietf.org/html/rfc6901). + * See `GetItemByPointer()` for more information. + * + * Referred value must be stored as `ByteBox` or `ByteRef` + * (or one of their sub-classes) for this method to work. + * + * @param jsonPointerAsText Description of a path to the `byte` value. + * @param defaultValue Value to return in case `jsonPointerAsText` + * does not point at any existing value or if that value does not have + * appropriate type. + * @return `byte` value, stored at `jsonPointerAsText` or `defaultValue` if it + * is missing or has a different type. + */ +public final function byte GetByteByPointer( + Text jsonPointerAsText, + optional byte defaultValue) +{ + local AcediaObject result; + local ByteBox asBox; + local ByteRef asRef; + result = GetItemByPointer(jsonPointerAsText); + if (result == none) { + return defaultValue; + } + asBox = ByteBox(result); + if (asBox != none) { + return asBox.Get(); + } + asRef = ByteRef(result); + if (asRef != none) { + return asRef.Get(); + } + return defaultValue; +} + +/** + * Returns a `int` value stored (in the caller `Collection` or + * one of it's sub-collections) pointed by + * [JSON pointer](https://tools.ietf.org/html/rfc6901). + * See `GetItemByPointer()` for more information. + * + * Referred value must be stored as `IntBox` or `IntRef` + * (or one of their sub-classes) for this method to work. + * + * @param jsonPointerAsText Description of a path to the `int` value. + * @param defaultValue Value to return in case `jsonPointerAsText` + * does not point at any existing value or if that value does not have + * appropriate type. + * @return `int` value, stored at `jsonPointerAsText` or `defaultValue` if it + * is missing or has a different type. + */ +public final function int GetIntByPointer( + Text jsonPointerAsText, + optional int defaultValue) +{ + local AcediaObject result; + local IntBox asBox; + local IntRef asRef; + result = GetItemByPointer(jsonPointerAsText); + if (result == none) { + return defaultValue; + } + asBox = IntBox(result); + if (asBox != none) { + return asBox.Get(); + } + asRef = IntRef(result); + if (asRef != none) { + return asRef.Get(); + } + return defaultValue; +} + +/** + * Returns a `float` value stored (in the caller `Collection` or + * one of it's sub-collections) pointed by + * [JSON pointer](https://tools.ietf.org/html/rfc6901). + * See `GetItemByPointer()` for more information. + * + * Referred value must be stored as `FloatBox` or `FloatRef` + * (or one of their sub-classes) for this method to work. + * + * @param jsonPointerAsText Description of a path to the `float` value. + * @param defaultValue Value to return in case `jsonPointerAsText` + * does not point at any existing value or if that value does not have + * appropriate type. + * @return `float` value, stored at `jsonPointerAsText` or `defaultValue` if it + * is missing or has a different type. + */ +public final function float GetFloatByPointer( + Text jsonPointerAsText, + optional float defaultValue) +{ + local AcediaObject result; + local FloatBox asBox; + local FloatRef asRef; + result = GetItemByPointer(jsonPointerAsText); + if (result == none) { + return defaultValue; + } + asBox = FloatBox(result); + if (asBox != none) { + return asBox.Get(); + } + asRef = FloatRef(result); + if (asRef != none) { + return asRef.Get(); + } + return defaultValue; +} + +/** + * Returns a `Text` value stored (in the caller `Collection` or + * one of it's sub-collections) pointed by + * [JSON pointer](https://tools.ietf.org/html/rfc6901). + * See `GetItemByPointer()` for more information. + * + * Referred value must be stored as `Text` or `MutableText` + * (or one of their sub-classes) for this method to work. + * + * @param jsonPointerAsText Description of a path to the `Text` value. + * @return `Text` value, stored at `jsonPointerAsText` or `none` if it + * is missing or has a different type. + */ +public final function Text GetTextByPointer(Text jsonPointerAsText) +{ + local AcediaObject result; + local MutableText asMutable; + local Text asImmutable; + result = GetItemByPointer(jsonPointerAsText); + if (result == none) { + return none; + } + asMutable = MutableText(result); + if (asMutable != none) { + return asMutable; + } + asImmutable = Text(result); + if (asImmutable != none) { + return asImmutable; + } + return none; +} + +/** + * Returns an `AssociativeArray` value stored (in the caller `Collection` or + * one of it's sub-collections) pointed by + * [JSON pointer](https://tools.ietf.org/html/rfc6901). + * See `GetItemByPointer()` for more information. + * + * Referred value must be stored as `AssociativeArray` + * (or one of it's sub-classes) for this method to work. + * + * @param jsonPointerAsText Description of a path to the + * `AssociativeArray` value. + * @return `AssociativeArray` value, stored at `jsonPointerAsText` or + * `none` if it is missing or has a different type. + */ +public final function AssociativeArray GetAssociativeArrayByPointer( + Text jsonPointerAsText) +{ + return AssociativeArray(GetItemByPointer(jsonPointerAsText)); +} + +/** + * Returns an `DynamicArray` value stored (in the caller `Collection` or + * one of it's sub-collections) pointed by + * [JSON pointer](https://tools.ietf.org/html/rfc6901). + * See `GetItemByPointer()` for more information. + * + * Referred value must be stored as `DynamicArray` + * (or one of it's sub-classes) for this method to work. + * + * @param jsonPointerAsText Description of a path to the + * `DynamicArray` value. + * @return `DynamicArray` value, stored at `jsonPointerAsText` or + * `none` if it is missing or has a different type. + */ +public final function DynamicArray GetDynamicArrayByPointer( + Text jsonPointerAsText) +{ + return DynamicArray(GetItemByPointer(jsonPointerAsText)); +} + defaultproperties { + TSLASH = 0 + stringConstants(0) = "/" + TJSON_ESCAPE = 1 + stringConstants(1) = "~" + TJSON_ESCAPED_SLASH = 2 + stringConstants(2) = "~1" + TJSON_ESCAPED_ESCAPE = 3 + stringConstants(3) = "~0" } \ No newline at end of file diff --git a/sources/Data/Collections/DynamicArray.uc b/sources/Data/Collections/DynamicArray.uc index 2b0fdfa..7808b1d 100644 --- a/sources/Data/Collections/DynamicArray.uc +++ b/sources/Data/Collections/DynamicArray.uc @@ -5,7 +5,7 @@ * `AcediaObject`s. * Appropriate classes and APIs for their construction are provided for * main primitive types and can be extended to any custom `struct`. - * Copyright 2020 Anton Tarasenko + * Copyright 2020 - 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -474,6 +474,21 @@ public final function int Find(AcediaObject item) return -1; } +protected function AcediaObject GetByText(MutableText key) +{ + local int index, consumed; + local Parser parser; + parser = _.text.Parse(key); + parser.MUnsignedInteger(index,,, consumed); + if (!parser.Ok()) + { + parser.FreeSelf(); + return none; + } + parser.FreeSelf(); + return GetItem(index); +} + defaultproperties { iteratorClass = class'DynamicArrayIterator' diff --git a/sources/Data/Collections/Tests/TEST_CollectionsMixed.uc b/sources/Data/Collections/Tests/TEST_CollectionsMixed.uc new file mode 100644 index 0000000..18ea1b9 --- /dev/null +++ b/sources/Data/Collections/Tests/TEST_CollectionsMixed.uc @@ -0,0 +1,105 @@ +/** + * Set of tests for `AssociativeArray` class. + * Copyright 2020 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class TEST_CollectionsMixed extends TestCase + abstract; + +var protected const string complexJSONObject; + +protected static function TESTS() +{ + Context("Testing accessing collections by JSON pointers."); + Test_GetByPointer(); + Test_GetTypeByPointer(); +} + +protected static function Test_GetByPointer() +{ + local AcediaObject result; + local AssociativeArray obj; + Issue("`GetItemByPointer()` does not return correct objects."); + obj = __().json.ParseObjectWith( + __().text.ParseString(default.complexJSONObject)); + TEST_ExpectTrue(obj.GetItemByPointer(P("")) == obj); + result = obj.GetItemByPointer(P("/innerObject/array/1")); + TEST_ExpectNotNone(BoolBox(result)); + TEST_ExpectTrue(BoolBox(result).Get() == false); + result = obj.GetItemByPointer(P("/innerObject/array/3/maybe")); + TEST_ExpectNotNone(FloatBox(result)); + TEST_ExpectTrue(FloatBox(result).Get() == 0.003); + + Issue("`GetItemByPointer()` does not return correct objects when using" + @ "'~'-escaped sequences."); + result = obj.GetItemByPointer(P("/another~01var")); + TEST_ExpectNotNone(Text(result)); + TEST_ExpectTrue(Text(result).ToPlainString() == "aye!"); + result = obj.GetItemByPointer(P("/innerObject/one more/no~1pe")); + TEST_ExpectNotNone(IntBox(result)); + TEST_ExpectTrue(IntBox(result).Get() == 324532); + TEST_ExpectNotNone( + DynamicArray(obj.GetItemByPointer(P("/innerObject/array")))); + + Issue("`GetItemByPointer()` does not return `none` for incorrect pointers"); + TEST_ExpectNone(obj.GetItemByPointer(P("innerObject/array"))); + TEST_ExpectNone(obj.GetItemByPointer(P("//"))); + TEST_ExpectNone(obj.GetItemByPointer(P("/innerObject/array/5"))); + TEST_ExpectNone(obj.GetItemByPointer(P("/innerObject/array/-1"))); + TEST_ExpectNone(obj.GetItemByPointer(P("/innerObject/array/"))); +} + +protected static function Test_GetTypeByPointer() +{ + local AssociativeArray obj; + obj = __().json.ParseObjectWith( + __().text.ParseString(default.complexJSONObject)); + obj.SetItem(P("byte"), __().ref.byte(56)); + Issue("`GetByPointer()` methods do not return correct" + @ "existing values."); + TEST_ExpectTrue(obj.GetAssociativeArrayByPointer(P("")) == obj); + TEST_ExpectNotNone(obj.GetDynamicArrayByPointer(P("/innerObject/array"))); + TEST_ExpectTrue( + obj.GetBoolByPointer(P("/innerObject/array/1"), true) + == false); + TEST_ExpectTrue(obj.GetByteByPointer(P("/byte"), 128) == 56); + TEST_ExpectTrue(obj.GetIntByPointer(P("/innerObject/my_int")) == -9823452); + TEST_ExpectTrue(obj + .GetFloatByPointer(P("/innerObject/array/4"), 2.34) + == 56.6); + TEST_ExpectTrue(obj + .GetTextByPointer(P("/innerObject/one more/o rly?")).ToPlainString() + == "ya rly"); + Issue("`GetByPointer()` methods do not return default value for" + @ "incorrect pointers."); + TEST_ExpectTrue( + obj.GetBoolByPointer(P("/innerObject/array/20"), true) + == true); + TEST_ExpectTrue(obj.GetByteByPointer(P("/byte/"), 128) == 128); + TEST_ExpectTrue(obj.GetIntByPointer(P("/innerObject/my int")) == 0); + TEST_ExpectTrue(obj + .GetFloatByPointer(P("/innerObject/array"), 2.34) + == 2.34); + TEST_ExpectNone(obj.GetTextByPointer(P(""))); +} + +defaultproperties +{ + caseGroup = "Collections" + caseName = "Common methods" + complexJSONObject = "{\"innerObject\":{\"my_bool\":true,\"array\":[\"Engine.Actor\",false,null,{\"something \\\"here\\\"\":\"yes\",\"maybe\":0.003},56.6],\"one more\":{\"no/pe\":324532,\"whatever\":false,\"o rly?\":\"ya rly\"},\"my_int\":-9823452},\"some_var\":-7.32,\"another~1var\":\"aye!\"}" +} \ No newline at end of file diff --git a/sources/Manifest.uc b/sources/Manifest.uc index d0fdf86..4cb36f0 100644 --- a/sources/Manifest.uc +++ b/sources/Manifest.uc @@ -46,8 +46,9 @@ defaultproperties testCases(12) = class'TEST_Memory' testCases(13) = class'TEST_DynamicArray' testCases(14) = class'TEST_AssociativeArray' - testCases(15) = class'TEST_Iterator' - testCases(16) = class'TEST_Command' - testCases(17) = class'TEST_CommandDataBuilder' - testCases(18) = class'TEST_LogMessage' + testCases(15) = class'TEST_CollectionsMixed' + testCases(16) = class'TEST_Iterator' + testCases(17) = class'TEST_Command' + testCases(18) = class'TEST_CommandDataBuilder' + testCases(19) = class'TEST_LogMessage' } \ No newline at end of file