diff --git a/sources/Data/Database/Connection/DBCache.uc b/sources/Data/Database/Connection/DBCache.uc new file mode 100644 index 0000000..22ee9b8 --- /dev/null +++ b/sources/Data/Database/Connection/DBCache.uc @@ -0,0 +1,1099 @@ +/** + * Object designed to allow for locally caching database's data and tracking + * all applied changes, even if database is yet to respond/rejected them. + * This includes tracking changes even *before* database's data is available, + * storing them inside as a series to edits to apply. + * Copyright 2023 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 DBCache extends AcediaObject; + +/** + * # `DBCache` + * + * Object designed to allow for locally caching database's data and tracking + * all applied changes, even if database is yet to respond/rejected them. + * This includes tracking changes even *before* database's data is available, + * storing them inside as a series to edits to apply. + * + * ## Usage + * + * You can simply read and write JSON data with `Read(JSONPointer)` and + * `Write(JSONPointer, AcediaObject)` right after `DBCache`'s creation. + * Once real database's data has arrived, you can set it with `SetRealData()`. + * Data recorded before the `SetRealData()` call is an *approximation* and + * might not function as a real JSON value/database. Because `DBCache` doesn't + * yet know the real data in the database and even if you expect there to be + * a certain hierarchy of objects/arrays - `DBCache` cannot perform checks that + * they are there. This is why it simply lets you write any data at any path + * like "/A/B/C" in hopes that it data will be there after `SetRealData()` + * call. + * You can also "pre-create" such data by calling `Increment()` method with + * empty `Collection`s: + * + * ```unrealscript + * local DBCache cache; + * local JSONPointer dataLocation,; + * local HashTable emptyObject; + * + * cache = DBCache(_.memory.Allocate(class'DBCache')); + * emptyObject = _.collections.EmptyHashTable(); + * dataLocation = _.json.Pointer(); + * cache.Increment(dataLocation, emptyObject); + * cache.Push(P("A")); + * cache.Increment(dataLocation, emptyObject); + * cache.Push(P("B")); + * cache.Increment(dataLocation, emptyObject); + * cache.Push(P("C")); + * _.memory.Free(emptyObject); + * _.memory.Free(dataLocation); + * ``` + * + * After `SetRealData()` edits you've made prior will be reapplied to + * provided data and you'll get report on what edits were attempted and what + * have failed: + * + * ```unrealscript + * local array completedEdits; + * // ... + * completedEdits = localCache.SetRealData(data); + * for (i = 0; i < completedEdits.length; i += 1) + * { + * if (completedEdits[i].successful) { + * // Do something joyful! + * } + * else { + * // Wail in your misery! + * } + * _.memory.Free(completedEdits[i].location); + * _.memory.Free(completedEdits[i].data); + * } + * ``` + * + * One more example of appending arrays using "-" JSON pointer component. + * + * ```unrealscript + * local DBCache cache; + * local JSONPointer newDataLocation, arrayLocation; + * local Text data; + * + * cache = DBCache(_.memory.Allocate(class'DBCache')); + * // "-" in JSON pointer allows us to append to array from the end + * newDataLocation = _.json.Pointer_S("/array/-"); + * data = _.text.FromString("Just new data!"); + * cache.Write(newDataLocation, data); + * + * // Now add database's data: supposing `dbData` was a following JSON object: + * // {"array": [1, 3, true], "tag": "db data!"} + * cache.SetRealData(dbData); + * + * // Now read changes back:t + * arrayLocation = _.json.Pointer_S("/array"); + * cache.ReadData(arrayLocation); + * // ^ returns `ArrayList` with following contents: + * [1, 3, true, "Just new data!"] + * ``` + * + * ## Implementation + * + * Cache can be in two distinct states: before (`cachedData` is `false`) + * and after (`cachedData` is `true`) obtaining database's actual data. + * In the second state it simply stores `AcediaObject` that represents stored + * JSON value and applies all changes to it directly / reads from it directly. + * In case database's data wasn't yet obtained - stores all valid `Write()` + * requests as an array of edits `pendingEdits`. Any `Read()` causes us to go + * through that array until we: + * + * 1. Find an edit that could've written a data user; + * 2. Obtain necessary data from that edit (we might want some folded + * sub-object); + * 3. Reapply all later edits to that data and return it. + * + * We also use similar process when adding new edits during `Write()`: + * if we know for a fact that at "/a/b/c" is a non-container + * (like a JSON string or number), then we simply reject any writes to its + * sub-data like "/a/b/c/d", since it is impossible to write anything inside. + * This also applies to the JSON arrays if we want to write into them using + * non-numeric keys. + * Additionally, for the sake of efficiency, `DBCache` erases old edits in + * case their data gets completely overwritten by new ones: if we first write + * something inside "/array/1" and then rewrite the whole "/array" - we no + * longer need to store the first edit for anything. + * + * ## Remarks + * + * Before `SetRealData()` is called, the collection inside `DBCache` is mostly + * faked. In most practical cases it shouldn't noticeable and the most notable + * issue one can stumble on is that `DBCache` allows to write data at paths, it + * is not sure even exist, leading to weird behavior: + * + * ```unrealscript + * local DBCache cache; + * local JSONPointer newDataLocation, objectLocation; + * local Text data; + * + * cache = DBCache(_.memory.Allocate(class'DBCache')); + * // "-" in JSON pointer allows us to append to array from the end + * newDataLocation = _.json.Pointer_S("/subObject/field"); + * data = _.text.FromString("Just new data!"); + * cache.Write(newDataLocation, data); + * + * // Without adding database's data with `SetRealData()` we get: + * objectLocation = _.json.Pointer_S("/subObject"); + * // returns `none`, since we have no info about data at that path + * cache.ReadData(objectLocation); // none + * // But still remembers that this is "Just new data!" + * cache.ReadData(newDataLocation); // "Just new data!" + * ``` + */ + +enum DBCacheEditType +{ + DBCET_Write, + DBCET_Increment, + DBCET_Remove +}; + +// Represents a single edit made as a result of `Write()` call +struct PendingEdit +{ + var public DBCacheEditType type; + var public JSONPointer location; + var public AcediaObject data; + var public bool successful; +}; +// All valid edits made so far (minus impossible and overwritten ones) in +// order they were made: the lower index, the older the edit. +var private array pendingEdits; + +// Was data already cached? +// We cannot simply use `cachedData == none`, since `none` is a valid value. +var private bool isDataCached; +// Data, obtained from the database +var private AcediaObject cachedData; + +protected function Finalizer() +{ + local int i; + + for (i = 0; i < pendingEdits.length; i += 1) { + FreePendingWrite(pendingEdits[i]); + } + pendingEdits.length = 0; + _.memory.Free(cachedData); + cachedData = none; + isDataCached = false; +} + +/** + * Reads data from `DBCache` stored at the pointer `location`. + * If no data is recorded at `location`, returns `none`. + * + * NOTE: If the real database's data wasn't yet set with `SetRealData()`, + * then this method can return `none` for path like "/a/b", even if value + * "/a/b/c" was already set. This is because `DBCache` doesn't try to guess + * types of containers on the way to the recorded data: if 'b' were to + * be numeric - then we'd have no idea whether it is an array of an object. + * + * @param location Location inside `DBCache`'s stored data, from which to + * read data of interest. + * @return Data stored at location given by `location`, `none` if nothing is + * stored there. + */ +public final function AcediaObject Read(JSONPointer location) +{ + local Collection cachedCollection; + + if (location == none) { + return none; + } + if (!isDataCached) { + return ReadPending(location); + } + if (location.IsEmpty()) { + return _.json.Copy(cachedData); + } + // For non-empty pointers, `cachedCollection` must be a `Collection` + cachedCollection = Collection(cachedData); + if (cachedCollection != none) { + return cachedCollection.GetItemByJSON(location); + } + return none; +} + +/** + * Writes data into `DBCache` at the given location. + * + * This method can work differently depending on whether `SetRealData()` call + * was already made: + * + * 1. Before the `SetRealData()` call it basically knows nothing about + * object inside database (the real data) and will freely write at any + * location as long as that operation won't contradict previous edits + * (it will attempt to recognize and prevent removing sub-values inside + * null, booleans, strings and numbers or non-numeric keys into + * arrays). + * 2. After the `SetRealData()` call it already knows what data was stored + * inside before edits were made and would only allow to write data at + * "/path/to/value" if "/path/to" (i.e. path without its last + * component) corresponds to an appropriate collection (JSON object or + * JSON array if last component is numeric or "-"). + * + * @param location Location into which to write new data. + * @param data Data to write, expected to consist only of + * the JSON-compatible types. It will be copied with `_.json.Copy()`, + * so the reference won't be kept. + * @return `true` if write was successful and `false` otherwise. Note that + * operations made before `SetRealData()` can be reported as successful, + * but then rejected after the real data is set if they're incompatible + * with its structure (@see `SetRealData()` for more information). + */ +public final function bool Write(JSONPointer location, AcediaObject data) +{ + local Collection cachedCollection; + + if (location == none) { + return false; + } + if (!isDataCached) { + return AddPendingEdit(location, data, DBCET_Write); + } + if (location.IsEmpty()) + { + _.memory.Free(cachedData); + cachedData = _.json.Copy(data); + return true; + } + cachedCollection = Collection(cachedData); + // At this point `EditJSONCollection()`'s contract of + // `cachedCollection != none` and `location` isn't `none` or empty is + // satisfied. + if (cachedCollection != none) + { + return EditJSONCollection( + cachedCollection, + location, + data, + DBCET_Write); + } + return false; +} + +/** + * Removes data from `DBCache` at the given location. + * + * This method can work differently depending on whether `SetRealData()` call + * was already made: + * + * 1. Before the `SetRealData()` call it basically knows nothing about + * object inside database (the real data) and will freely perform + * removal at any location as long as that operation won't obviously + * contradict previous edits (it will attempt to recognize and prevent + * removing sub-values inside null, booleans, strings and numbers or + * non-numeric keys into arrays). + * 2. After the `SetRealData()` call it already knows what data was stored + * inside before edits were made and would only allow to write data at + * "/path/to/value" if "/path/to" (i.e. path without its last + * component) corresponds to an appropriate collection (JSON object or + * JSON array if last component is numeric or "-"). + * + * @param location Location from which to remove all data. + * @return `true` if removal was successful and `false` otherwise. Note that + * operations made before `SetRealData()` can be reported as successful, + * but then rejected after the real data is set if they're incompatible + * with its structure (@see `SetRealData()` for more information). + */ +public final function bool Remove(JSONPointer location) +{ + local Collection cachedCollection; + + if (location == none) { + return false; + } + if (!isDataCached) { + return AddPendingEdit(location, none, DBCET_Remove); + } + if (location.IsEmpty()) + { + _.memory.Free(cachedData); + cachedData = none; + return true; + } + cachedCollection = Collection(cachedData); + // At this point `EditJSONCollection()`'s contract of + // `cachedCollection != none` and `location` isn't `none` or empty is + // satisfied. + if (cachedCollection != none) + { + return EditJSONCollection( + cachedCollection, + location, + none, + DBCET_Remove); + } + return false; +} + +/** + * Increments data inside `DBCache` at the given location. + * + * @see `_.json.Increment()`. + * + * This method can work differently depending on whether `SetRealData()` call + * was already made: + * + * 1. Before the `SetRealData()` call it basically knows nothing about + * object inside database (the real data) and will freely increment at + * any location as long as that operation won't contradict previous + * edits (it will attempt to recognize and prevent removing sub-values + * inside null, booleans, strings and numbers or non-numeric keys into + * arrays). + * 2. After the `SetRealData()` call it already knows what data was stored + * inside before edits were made and would only allow to incrementing + * data at "/path/to/value" if "/path/to" (i.e. path without its last + * component) corresponds to an appropriate collection (JSON object or + * JSON array if last component is numeric or "-"). + * + * @param location Location of the value to increment with new, given data. + * @param data Data to increment with, expected to consist only of + * the JSON-compatible types. It will be copied with `_.json.Copy()`, + * so the reference won't be kept. + * @return `true` if increment was successful and `false` otherwise. Note that + * operations made before `SetRealData()` can be reported as successful, + * but then rejected after the real data is set if they're incompatible + * with its structure (@see `SetRealData()` for more information). + */ +public final function bool Increment(JSONPointer location, AcediaObject data) +{ + local AcediaObject incrementedRoot; + local Collection cachedCollection; + + if (location == none) { + return false; + } + if (!isDataCached) { + return AddPendingEdit(location, data, DBCET_Increment); + } + cachedCollection = Collection(cachedData); + if (cachedCollection != none) { + return EditJSONCollection( + cachedCollection, + location, + data, + DBCET_Increment); + } + else if (location.IsEmpty()) + { + incrementedRoot = _.json.increment(cachedData, data); + _.memory.Free(cachedData); + cachedData = incrementedRoot; + return true; + } + return false; +} +/*INC 2 +Cache inc 1 {} /test True +Cache inc 2 +Cache inc 3 +Cache inc 4 +INC 5 +WriteDataByJSON #1 +WriteDataByJSON #2 */ +/** + * Checks whether `SetRealData()` was called. + * + * @return `true` if `SetRealData()` was called and `DBCache` is in + * second mode, working on the given, cached data instead of the edits. + * `false` otherwise. + */ +public final function bool IsRealDataSet() +{ + return isDataCached; +} + +/** + * Sets real data that `DBCache` must use as basis for all of its changes. + * + * Any valid changes (for which `Write()` has previously returned `true` and + * which weren't overwritten by later changes) will be reapplied to given + * object and whether applying each edit ended in success of failure will be + * reported in the returned value. + * + * Can only be called once, all subsequent call will do nothing and will return + * empty array. + * + * @param realData Data to use as basis, expected to consist only of + * the JSON-compatible types. It will be copied with `_.json.Copy()`, + * so the reference won't be kept. + * @return All valid edits made so far (minus impossible and overwritten ones) + * in order they were made: the lower index, the older the edit. Includes + * a boolean flag that indicates whether each particular edit was + * successfully applied to given data. + */ +public final function array SetRealData(AcediaObject realData) +{ + local int i; + local Collection cachedCollection; + local array pendingEditsCopy; + + if (isDataCached) { + return pendingEdits; + } + cachedData = _.json.Copy(realData); + for (i = 0; i < pendingEdits.length; i += 1) + { + cachedCollection = Collection(cachedData); + if ( pendingEdits[i].location.IsEmpty() + && pendingEdits[i].type != DBCET_Increment) + { + // Generally `pendingEdits[i].location.IsEmpty()` should be `true` + // for non-incrementing operations only for one index, since all + // edits would get overwritten be overwritten by the newest one, + // but let's do these changes just in case. + _.memory.Free(cachedData); + if (pendingEdits[i].type == DBCET_Write) + { + pendingEdits[i].successful = true; + cachedData = _.json.Copy(pendingEdits[i].data); + } + else // (pendingEdits[i].type == DBCET_Remove) + { + pendingEdits[i].successful = (cachedData != none); + cachedData = none; + } + } + else if (cachedCollection != none) + { + // Any other edits affect sub-objects and can, therefore, only be + // applied to `Collection`s (JSON objects and arrays). + pendingEdits[i].successful = EditJSONCollection( + cachedCollection, + pendingEdits[i].location, + pendingEdits[i].data, + pendingEdits[i].type); + } + } + pendingEditsCopy = pendingEdits; + pendingEdits.length = 0; + isDataCached = true; + return pendingEditsCopy; +} + +// For reading data when before the `SetRealData()` call. +private final function AcediaObject ReadPending(JSONPointer location) +{ + local int nextEditIndex; + local int newestOverridingEdit; + + if (location == none) { + return none; + } + // Go from the newest to the latest edit and find newest edit that + // *completely overwrites* data at `location`. + // This can be any newest pointer that serves as prefix to `location` + // that *writes* or *removes* data. + // If there are only *append* edits, we need to take the oldest one, + // since it is possible for several appending edits to stuck on top of each + // other. + newestOverridingEdit = -1; + nextEditIndex = pendingEdits.length - 1; + while (nextEditIndex >= 0) + { + + if (location.StartsWith(pendingEdits[nextEditIndex].location)) + { + newestOverridingEdit = nextEditIndex; + if (pendingEdits[nextEditIndex].type != DBCET_Increment) { + break; + } + } + nextEditIndex -= 1; + } + if (newestOverridingEdit >= 0) { + return ReconstructFromEdit(location, newestOverridingEdit); + } + return none; +} + +// Takes data from the given edit from `pendingEdits` as a basis and +// reapplies newer applicable edits to it. +// Assumes `location` is not `none`. +// Assumes `pendingEdits[editIndex].location` is prefix of `location`. +private final function AcediaObject ReconstructFromEdit( + JSONPointer location, + int editIndex) +{ + local int startIndex; + local AcediaObject result; + local Collection outerCollection; + + outerCollection = Collection(pendingEdits[editIndex].data); + if (location.GetLength() == pendingEdits[editIndex].location.GetLength()) { + // In case pending edit was made at the exactly `location`, simply copy + // its data, since `location` is pointing right at it. + result = _.json.Copy(pendingEdits[editIndex].data); + } + else if (outerCollection != none) + { + // Otherwise `location` is pointing deeper than + // `pendingEdits[editIndex].location` and we need to return + // a sub-object. This means that `data` has to be a `Collection`. + // First find that `Collection` (stored inside `outerCollection`), + // pointed by `location` inside `pendingEdits[editIndex].data` + // (with removed `pendingEdits[editIndex].location` prefix). + startIndex = pendingEdits[editIndex].location.GetLength(); + result = ApplyPointer( + outerCollection, + location, + startIndex, + location.GetLength()); + // We can safely release our rights to keep `result` reference and + // still use it, since it is still stored `outerCollection` and won't + // get deallocated. + _.memory.Free(result); + // `startIndex` is an `out` variable that records how far + // `ApplyPointer()` was able to travel along `location`. + // We are only successful if we reached the end. + if (startIndex == location.GetLength()) { + result = _.json.Copy(result); + } + else { + result = none; + } + } + ApplyCorrectingWrites(result, location, editIndex + 1); + return result; +} + +// Attempts to apply all sufficiently new (at least with index +// `startIndex`) edits to the `target`. +// Assumes `locationToCorrect` in not `none`. +private final function ApplyCorrectingWrites( + out AcediaObject target, + JSONPointer locationToCorrect, + int startIndex) +{ + local int i; + local Collection targetAsCollection; + local JSONPointer subLocation, nextLocation;; + + if (target == none) { + return; + } + for (i = startIndex; i < pendingEdits.length; i += 1) + { + nextLocation = pendingEdits[i].location; + if (!nextLocation.StartsWith(locationToCorrect)) { + continue; + } + targetAsCollection = Collection(target); + // `Collection`s (JSON arrays or objects) have to be handled + // differently, since we might need to change values stored deep within + // the `Collection`, while for other variables we can change `target` + // directly. + if (targetAsCollection != none) + { + subLocation = nextLocation.Copy(locationToCorrect.GetLength()); + EditJSONCollection( + Collection(target), + subLocation, + pendingEdits[i].data, + pendingEdits[i].type); + subLocation.FreeSelf(); + } + else if (nextLocation.GetLength() == locationToCorrect.GetLength()) + { + EditJSONSimpleValue( + target, + pendingEdits[i].data, + pendingEdits[i].type); + } + } +} + +// Applies operation of type `editType` to the given object. +// Assumes that `target` isn't `none`. +// Makes a copy of the `value`. +private final function bool EditJSONSimpleValue( + out AcediaObject target, + AcediaObject value, + DBCacheEditType editType) +{ + local AcediaObject newTarget; + + if (editType == DBCET_Write) { + newTarget = _.json.Copy(value); + } + else if (editType == DBCET_Remove) { + newTarget = none; + } + else + { + newTarget = _.json.Increment(target, value); + if (newTarget == none) { + return false; + } + } + _.memory.Free(target); + target = newTarget; +} + +// Applies operation of type `editType` to the object stored inside +// given `Collection`, given by `location`. +// Makes a copy of the `value`. +// Assumes that `location` can only be an empty pointer if `editType` is +// `DBCET_Increment`. +// Assumes that `target` isn't `none`. +private final function bool EditJSONCollection( + Collection target, + JSONPointer location, + AcediaObject value, + DBCacheEditType editType) +{ + local bool success; + local Text key; + local ArrayList arrayCollection; + local HashTable objectCollection; + local Collection innerCollection; + local JSONPointer poppedLocation; + local AcediaObject valueCopy; + + // Empty pointer is only allowed if we're incrementing; + if (location.IsEmpty()) + { + return (editType == DBCET_Increment + && IncrementCollection(target, value)); + } + // First get `Collection` that stores data, pointed by `location` + // (which is data pointed by `location` without the last segment). + // Last segment will serve as a key in that `Collection`, so also + // keep it. + poppedLocation = location.Copy(); + key = poppedLocation.Pop(); + innerCollection = target.GetCollectionByJSON(poppedLocation); + // Then, depending on the collection, get the actual data + arrayCollection = ArrayList(innerCollection); + objectCollection = HashTable(innerCollection); + valueCopy = _.json.Copy(value); + if (arrayCollection != none) + { + success = EditArrayList( + arrayCollection, + key, + value, + editType, + location.PopNumeric(true)); + } + if (objectCollection != none) { + success = EditHashTable(objectCollection, key, value, editType); + } + _.memory.Free(innerCollection); + _.memory.Free(poppedLocation); + _.memory.Free(key); + _.memory.Free(valueCopy); + return success; +} + +// Assumes `collection != none` and `key != none` +// Assumes value is already copied and won't be stored anywhere else +private final function bool EditArrayList( + ArrayList collection, + Text key, + AcediaObject value, + DBCacheEditType editType, + int numericKey) +{ + local AcediaObject incrementedValue; + + // Only valid case of `numericKey < 0` is when `key` is "-", which can only + // be used with `DBCET_Write` to append to `collection` + if (numericKey < 0 && editType != DBCET_Write) { + return false; + } + if (editType == DBCET_Write) + { + if (numericKey >= 0) { + collection.SetItem(numericKey, value); + } + else if (key.IsEqual(P("-"))) { + collection.AddItem(value); + } + else { + return false; + } + } + else if (editType == DBCET_Remove) + { + if (numericKey >= collection.GetLength()) { + return false; + } + collection.RemoveIndex(numericKey); + return true; + } + else // if (editType == DBCET_Increment) + { + if (value == none) + { + if (numericKey >= collection.GetLength()) { + collection.SetItem(numericKey, none); + } + // Incrementing by `none` is a success for any reachable value + // (including a missing one, if the immediate parent is present) + return true; + } + incrementedValue = + EfficientIncrement(collection.GetItem(numericKey), value); + if (incrementedValue != none) { + collection.SetItem(numericKey, incrementedValue); + } + _.memory.Free(incrementedValue); // `none` or moved into `collection` + return (incrementedValue != none); + } + return true; +} + +// Assumes `collection != none` and `key != none` +// Assumes value is already copied and won't be stored anywhere else +private final function bool EditHashTable( + HashTable collection, + Text key, + AcediaObject value, + DBCacheEditType editType) +{ + local AcediaObject incrementedValue; + + if (editType == DBCET_Write) { + collection.SetItem(key, value); + } + else if (editType == DBCET_Remove) + { + if (!collection.HasKey(key)) { + return false; + } + collection.RemoveItem(key); + return true; + } + else // if (editType == DBCET_Increment) + { + if (value == none) + { + if (!collection.HasKey(key)) { + collection.SetItem(key, none); + } + // Incrementing by `none` is a success for any reachable value + // (including a missing one, if the immediate parent is present) + return true; + } + incrementedValue = EfficientIncrement(collection.GetItem(key), value); + if (incrementedValue != none) { + collection.SetItem(key, incrementedValue); + } + _.memory.Free(incrementedValue); // `none` or moved into `collection` + return (incrementedValue != none); + } + return true; +} + +// This method is supposed to be more efficient than +// `_.json.Increment()` because it can skip copying `valueToIncrement` +// in case it's a collection and append to it directly. +// Assumes `increment` is not `none`. +// Returning `none` means increment has failed (could only happen possible +// if `increment` is `none`, which is impossible) +private final function AcediaObject EfficientIncrement( + /*take*/ AcediaObject valueToIncrement, + AcediaObject increment) +{ + local AcediaObject incrementedValue; + + if (valueToIncrement == none) { + return _.json.Copy(increment); + } + // This is the "efficient part": we first try to directly append + // `increment` to `valueToIncrement`, since it can avoid unnecessary + // copying of huge collections + if ( Collection(valueToIncrement) != none + && valueToIncrement.class == increment.class) + { + // If we're inside, then we are sure that both arguments are either + // `ArrayList`s or `HashTable`s (and not `none`!) + IncrementCollection(valueToIncrement, increment); + // We reuse `valueToIncrementAsHashTable`, so simply return reference + // we took ownership of + return valueToIncrement; + } + // Since all correct `Collection` cases were handled above, we only need to + // do the normal, "inefficient" incrementing when both arguments aren't + // `Collection`s + if (Collection(valueToIncrement) == none && Collection(increment) == none) { + incrementedValue = _.json.Increment(valueToIncrement, increment); + } + // We do not reuse either `valueToIncrement`, so we should release it + _.memory.Free(valueToIncrement); + // This will be `none` in case `_.json.Increment()` wasn't called + return incrementedValue; + +} + +// Increments `valueToIncrement` (changing its value) by `increment`. +// Only does work if both arguments are the same type of `Collection`. +// Returns `true` if it actually incremented `valueToIncrement` and `false` +// otherwise. +// If arguments have different types - does nothing. +private final function bool IncrementCollection( + AcediaObject valueToIncrement, + AcediaObject increment) +{ + local ArrayList valueToIncrementAsArrayList, incrementAsArrayList; + local HashTable valueToIncrementAsHashTable, incrementAsHashTable; + + valueToIncrementAsArrayList = ArrayList(valueToIncrement); + if (valueToIncrementAsArrayList != none) + { + incrementAsArrayList = ArrayList(increment); + if (incrementAsArrayList != none) + { + valueToIncrementAsArrayList.Append(incrementAsArrayList); + return true; + } + } + valueToIncrementAsHashTable = HashTable(valueToIncrement); + if (valueToIncrementAsHashTable != none) + { + incrementAsHashTable = HashTable(increment); + if (incrementAsHashTable != none) + { + valueToIncrementAsHashTable.Append(incrementAsHashTable); + return true; + } + } + return false; +} + +// For writing data when before the `SetRealData()` call. +// Assumes `location` isn't `none`. +private final function bool AddPendingEdit( + JSONPointer location, + AcediaObject data, + DBCacheEditType type) +{ + local int i, index; + local bool isIncrementing; + local AcediaObject leafItem; + local PendingEdit newWrite; + + // We basically just want to add new edit struct into `pendingEdits` + // array, but there's three additional consideration: + // + // 1. Some edits can be decided to be *impossible*: if, at the earlier + // stage, we wrote a simple type (not JSON array or object) at + // some location "/a/b" and then try to write a sub-object at + // the longer path "/a/b/c". This action is impossible and should + // be rejected outright, to prevent reading methods from reading + // data that will obviously be rejected on real object. + // NOTE: We only catch some of such cases, checking against + // the very first edit in the chain that build up data at + // `location`, since checking checking against data written by + // the other edits on top would require us to do too much work. + // 2. Some new edits can overwrite older ones: if we wrote something + // at location "/a/b/c" and then write something at "/a/b" - we can + // completely disregard and remove older edit at "/a/b/c". + // 3. Whenever we're doing incrementing edit, we want to be able to + // keep several of such edits at once (possibly on top of some + // writing edit), without them overwriting each other (as per + // previous point). There is also a special case for when we're + // writing into JSON array with pointer ending in "-" - it + // indicates adding a new element, which also makes it incrementing + // operation. + + // This variable will store whether `location` ends with "-" (writing + // operation corresponds to what was discussed in point 3) + isIncrementing = (type == DBCET_Increment) + || IsPointerAppendingToArray(location); + while (i < pendingEdits.length) + { + if (pendingEdits[i].location.StartsWith(location)) + { + // Here we're in situation described in point 2, where new edit + // will overwrite `pendingEdits[i].location`. + /*isSameLength = + (location.GetLength() == pendingEdits[i].location.GetLength());*/ + // Since `location` is prefix for `pendingEdits[i].location`, + // then it is either shorter or the same length. Same length here + // also means that these pointers are *identical*. + // We can prevent removal of the old rule only in situation of + // point 3, where new edit wants to increment an item (guaranteed + // by `isIncrementing`). + // + // NOTE: Here we make no check for whether we're writing into + // an object, which can result in us keeping several redundant + // edits. But we expect this to be a rare case and a fine trade off + // for skipping additional costly checks. + if (!isIncrementing) + { + FreePendingWrite(pendingEdits[i]); + pendingEdits.Remove(i, 1); + continue; + } + } + else if (pendingEdits[i].type == DBCET_Write + && location.StartsWith(pendingEdits[i].location)) + { + // Here we perform checks described in point 1: follow along + // the `location` pointer as far as possible and check if last + // known structure can at least in theory be explored by the rest + // of `location` after database's real data is loaded and set. + index = pendingEdits[i].location.GetLength(); + leafItem = ApplyPointer( + pendingEdits[i].data, + location, + index, + location.GetLength() - 1); + if ( Collection(leafItem) == none + || !IsKeyAcceptable(Collection(leafItem), location, index)) + { + return false; + } + } + i += 1; + } + // After all checks have passed and all older irrelevant edits were + // filtered out - add the new one. + newWrite.location = location.Copy(); + newWrite.data = _.json.Copy(data); + newWrite.type = type; + pendingEdits[pendingEdits.length] = newWrite; + return true; +} + +// Checks if `pointer`'s last component is "-", which denotes appending +// new item to the JSON array. +// Assumes `pointer != none`. +private final function bool IsPointerAppendingToArray(JSONPointer pointer) +{ + local int lastComponentIndex; + + lastComponentIndex = pointer.GetLength() - 1; + if (!pointer.IsComponentArrayApplicable(lastComponentIndex)) { + return false; + } + return (pointer.GetNumericComponent(lastComponentIndex) < 0); +} + +// Checks whether given key is acceptable for given collection. +// To avoid unnecessary copying the key is specified as a component of +// JSON pointer `path` with index `keyIndex`. +private final function bool IsKeyAcceptable( + Collection target, + JSONPointer path, + int keyIndex) +{ + local ArrayList arrayCollection; + + if (HashTable(target) != none) return true; + arrayCollection = ArrayList(target); + if (arrayCollection == none) return false; + if (keyIndex >= path.GetLength()) return true; + if (path.IsComponentArrayApplicable(keyIndex)) return true; + + return false; +} + +// Finds item inside `data` by using part of given JSON pointer as its own +// pointer. Part is defined as pointer given by components with indices inside +// `[from; to - 1]`. +// `from` is an `out` argument that will return index of pointer's +// component after that one used to obtained return value. +// E.g. if pointer is "/a/b/c/d" and we returned value at "/a/b", then +// `from` will contain index `2` of the component "c". +private final function AcediaObject ApplyPointer( + AcediaObject data, + JSONPointer pointer, + out int from, + int to) +{ + local int nextNumericKey; + local Text nextKey; + local ArrayList nextArray; + local HashTable nextObject; + + if (from < 0 || from > to) return none; + if (data == none) return none; + if (pointer == none) return none; + if (to > pointer.GetLength()) return none; + + // At each iteration in the `while` cycle below, `data` stores some + // reference to the next collection to "dig in" with our JSON pointer. + // This collection is normally obtained by `GetItem()` method on one of + // the iterations and, therefore, we own a reference to it that we must + // release. + // However, on the first iteration it is the same as passed argument + // and so we do not own it and cannot release it yet. We take ownership of + // it here yo hack around that issue. + data.NewRef(); + while (from < to) + { + nextObject = HashTable(data); + nextArray = ArrayList(data); + // Safe use `data` (and, therefore, both `nextObject` and `nextArray`) + // after this release, since `data` is either: + // 1. an argument and was added a reference before the loop + // 2. or is stored in another collection and, therefore, has + // another reference that way. + // Choice of `_.memory.Free()` instead of `self.FreeSelf()` is + // important here, since `data` can also be equal to `none`. + //_.memory.Free(data); + if (nextObject != none) + { + nextKey = pointer.GetComponent(from); + if (!nextObject.HasKey(nextKey)) + { + _.memory.Free(nextKey); + return nextObject; + } + data.FreeSelf(); + data = nextObject.GetItem(nextKey); + nextkey.FreeSelf(); + } + else if (nextArray != none) + { + nextNumericKey = pointer.GetNumericComponent(from); + if (nextNumericKey < 0 || nextNumericKey >= nextArray.GetLength()) { + return nextArray; + } + data.FreeSelf(); + data = nextArray.GetItem(nextNumericKey); + } + else { + // Not a collection => we cannot "go in" + return data; + } + from += 1; + } + return data; +} + +// Proper clean up of `PendingEdit` +private final function FreePendingWrite(PendingEdit edit) +{ + _.memory.Free(edit.location); + _.memory.Free(edit.data); +} + +defaultproperties +{ +} \ No newline at end of file diff --git a/sources/Data/Database/Connection/DBConnection.uc b/sources/Data/Database/Connection/DBConnection.uc new file mode 100644 index 0000000..3e35a18 --- /dev/null +++ b/sources/Data/Database/Connection/DBConnection.uc @@ -0,0 +1,791 @@ +/** + * Auxiliary object for simplifying working with databases. + * Copyright 2023 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 DBConnection extends AcediaObject + dependson(Database) + dependson(DBCache); + +/** + * # `DBConnection` + * + * Auxiliary object for simplifying working with databases. + * `Database` class has a rather simple interface and there are several issues + * that constantly arise when trying to use it: + * + * 1. If one tries to read/write data from/to specific location in + * the database, then `JSONPointer` has to be kept in addition to + * the `Database` reference at all times; + * 2. One has to perform initial checks about whether database can even be + * connected to, if at desired location there is a proper data + * structure, etc.. If one also wants to start using data before + * database's response or after its failure, then the same work of + * duplication that data locally must be performed. + * 3. Instead of immediate operations, database operations are delayed and + * user has to handle their results asynchronously in separate methods. + * + * `DBConnection` takes care of these issues by providing you synchronous + * methods for accessing cached version of the data at the given location that + * is duplicated to the database as soon as possible (and even if its no longer + * possible in the case of a failure). + * `DBConnection` makes immediate changes on the local cache and reports + * about possible failures with database later through signals `OnEditResult()` + * (reports about success of writing operations) and `OnStateChanged()` + * (reports about state changes of connected database, including complete + * failures). + * The only reading of database's values occurs at the moment of connecting + * to it, after that all the data is read from the local cache. + * Possible `DBConnection` states include: + * + * * `DBCS_Idle` - database was created, but not yet connected; + * * `DBCS_Connecting` - `Connect()` method was successfully called, but + * its result is still unknown; + * * `DBCS_Connected` - database is connected and properly working; + * * `DBCS_Disconnected` - database was manually disconnected and now + * operates solely on the local cache. Once disconnected `DBConnection` + * cannot be reconnected - create a new one instead. + * * `DBCS_FaultyDatabase` - database is somehow faulty. Precise reason + * can be found out with `GetError()` method: + * + * * `FDE_None` - no error has yet occurred; + * * `FDE_CannotReadRootData` - root data couldn't be read from + * the database, most likely because of the invalid `JSONPointer` + * for the root value; + * * `FDE_UnexpectedRootData` - root data was read from the database, + * but has an unexpected format. `DBConnection` expects either + * JSON object or array (can be specified which one) and this error + * occurs if its not found at specified database's location; + * * `FDE_Unknown` - database returned `DBR_InvalidDatabase` result + * for one of the queries. This is likely to happen when database + * is damaged. More precise details depend on the implementation, + * but result boils down to database being unusable. + * + * ## Usage + * + * Usage is straightforward: + * + * 1. Initialize with appropriate database by calling `Initialize()`; + * 2. Start connecting to it by calling `Connect()`; + * 3. Use `ReadDataByJSON()`/`WriteDataByJSON()` to read/write into + * the connected database; + * + * You can use it transparently even if database connection fails, but if you + * need to handle such failure - connect to the `OnStateChanged()` signal for + * tracking `DBCS_FaultyDatabase` state and to `OnEditResult()` for tracking + * success of writing operations. + * + * ## Implementation + * + * The brunt of work is done by `DBCache` and most of the logic in this + * class is for tracking state of the connection to the database and then + * reporting these changes through its own signals. + * The most notable hidden functionality is tracking requests by ID - + * `DBConnection` makes each request with unique ID and then stores them inside + * `requestIDs` (and in case of write requests - along with corresponding + * `JSONPointer` inside `queuedPointers`). This is necessary because: + * + * 1. Even if `DBConnection` gets reallocated - as far as UnrealScript is + * concerned it is still the same object, so the responses we no longer + * care about will still arrive. Keeping track of the IDs that interest + * us inside `requestIDs` allows us to filter out responses we no + * longer care about; + * 2. Tracking corresponding (to the IDs) `queuedPointers` also allow us + * to know responses to which writing requests we've received. + * + * ## Remarks + * + * Currently `DBConnection` doesn't support important feature of *incrementing* + * data that allows several sources to safely change the same value + * asynchronously. We're skipping on it right now to save time as its not + * really currently needed, however it will be added in the future. + */ + +enum DBConnectionState +{ + // `DBConnection` was created, but didn't yet attempt to connect + // to database + DBCS_Idle, + // `DBConnection` is currently connecting + DBCS_Connecting, + // `DBConnection` has already connected without errors + DBCS_Connected, + // `DBConnection` was manually disconnected + DBCS_Disconnected, + // `DBConnection` was disconnected because of the database error, + // @see `FaultyDatabaseError` for more. + DBCS_FaultyDatabase +}; +// Current connection state +var private DBConnectionState currentState; + +enum FaultyDatabaseError +{ + // No error has occurred yet + FDE_None, + // Root data isn't available + FDE_CannotReadRootData, + // Root data has incorrect format + FDE_UnexpectedRootData, + // Some internal error in database has occurred + FDE_Unknown +}; +// Reason for why current state is in `DBCS_FaultyDatabase` state; +// `FDE_None` if `DBConnection` is in any other state. +var private FaultyDatabaseError dbFailureReason; +// Keeps track whether root value read from the database was of the correct +// type. Only relevant after database tried connecting (i.e. it is in states +// `DBCS_Connected`, `DBCS_Disconnected` or `DBCS_FaultyDatabase`). +// This variable helps us determine whether error should be +// `FDE_CannotReadRootData` or `FDE_UnexpectedRootData`. +var private bool rootIsOfExpectedType; + +// `Database` + `JSONPointer` combo that point at the data we want to +// connect to +var private Database dbInstance; +var private JSONPointer rootPointer; +// Local, cached version of that data +var private DBCache localCache; + +// This is basically an array of (`int`, `JSONPointer`) pairs for tracking +// database requests of interest +var private array requestIDs; +var private array queuedPointers; +// Next usable ID. `DBConnection` is expected to always use unique IDs. +var private int nextRequestID; + + +var private DBConnection_StateChanged_Signal onStateChangedSignal; +var private DBConnection_EditResult_Signal onEditResultSignal; + +var private LoggerAPI.Definition errDoubleInitialization; + +/** + * Signal that will be emitted whenever `DBConnection` changes state. + * List of the available states: + * + * * `DBCS_Idle` - database was created, but not yet connected; + * * `DBCS_Connecting` - `Connect()` method was successfully called, but + * its result is still unknown; + * * `DBCS_Connected` - database is connected and properly working; + * * `DBCS_Disconnected` - database was manually disconnected and now + * operates solely on the local cache. Once disconnected `DBConnection` + * cannot be reconnected - create a new one instead. + * * `DBCS_FaultyDatabase` - database is somehow faulty. Precise reason + * can be found out with `GetError()` method. + * + * This method *is not* called when `DBConnection` is deallocated. + * + * [Signature] + * void ( + * DBConnection instance, + * DBConnectionState oldState, + * DBConnectionState newState) + * + * @param instance Instance of the `DBConnection` that has changed state. + * @param oldState State it was previously in. + * @param oldState New state. + */ +/* SIGNAL */ +public final function DBConnection_StateChanged_Slot OnStateChanged( + AcediaObject receiver) +{ + return DBConnection_StateChanged_Slot(onStateChangedSignal + .NewSlot(receiver)); +} + +/** + * Signal that will be emitted whenever `DBConnection` receives response from + * connected database about success of writing operation. + * + * Responses to old requests can still be received even if database got + * disconnected. + * + * Any emissions of this signal when the database's state is `DBCS_Connecting` + * correspond to reapplying edits made prior connection was established. + * + * [Signature] + * void (JSONPointer editLocation, bool isSuccessful) + * + * @param editLocation Location of the writing operation this is + * a response to. + * @param isSuccessful Whether writing operation ended in the success. + */ +/* SIGNAL */ +public final function DBConnection_EditResult_Slot OnEditResult( + AcediaObject receiver) +{ + return DBConnection_EditResult_Slot(onEditResultSignal.NewSlot(receiver)); +} + +protected function Constructor() +{ + localCache = DBCache(_.memory.Allocate(class'DBCache')); + onStateChangedSignal = DBConnection_StateChanged_Signal( + _.memory.Allocate(class'DBConnection_StateChanged_Signal')); + onEditResultSignal = DBConnection_EditResult_Signal( + _.memory.Allocate(class'DBConnection_EditResult_Signal')); +} + +protected function Finalizer() +{ + rootIsOfExpectedType = false; + currentState = DBCS_Idle; + _.memory.Free(dbInstance); + _.memory.Free(rootPointer); + _.memory.Free(localCache); + dbInstance = none; + rootPointer = none; + localCache = none; + _.memory.FreeMany(queuedPointers); + queuedPointers.length = 0; + requestIDs.length = 0; + // Free signals + _.memory.Free(onStateChangedSignal); + _.memory.Free(onEditResultSignal); + onStateChangedSignal = none; + onEditResultSignal = none; +} + +/** + * Initializes `DBConnection` with database and location to which it must be + * connected. + * + * For the initialization to be successful `DBConnection` must not yet be + * initialized and `initDatabase` be not `none`. + * + * To check whether caller `DBConnection` is initialized + * @see `IsInitialized()`. + * + * @param initDatabase Database with data we want to connect to. + * @param initRootPointer Location of said data in the given database. + * If `none` is specified, uses root object of the database. + * @return `true` if initialization was successful and `false` otherwise. + */ +public final function bool Initialize( + Database initDatabase, + optional JSONPointer initRootPointer) +{ + if (IsInitialized()) return false; + if (initDatabase == none) return false; + if (!initDatabase.IsAllocated()) return false; + + dbInstance = initDatabase; + dbInstance.NewRef(); + if (initRootPointer != none) { + rootPointer = initRootPointer.Copy(); + } + else { + rootPointer = _.json.Pointer(); + } + return true; +} + +/** + * Reads data from the `DBConnection` at the location defined by the given + * `JSONPointer`. + * + * If data was initialized with non-empty location for the root data, then + * actual returned data's location in the database is defined by appending + * given `pointer` to that root pointer. + * + * Data is actually always read from the local cache and, therefore, we can + * read data we've written via `DBConnection` even without actually connecting + * to the database. + * + * @param pointer Location from which to read the data. + * @return Data recorded for the given `JSONPointer`. `none` if it is missing. + */ +public final function AcediaObject ReadDataByJSON(JSONPointer pointer) +{ + return localCache.Read(pointer); +} + +/** + * Writes given data into the `DBConnection` at the location defined by + * the given `JSONPointer`. + * + * If data was initialized with non-empty location for the root data, then + * actual location for writing data in the database is defined by appending + * given `pointer` to that root pointer. + * + * Data is actually always also written into the local cache, even when + * there is no connection to the database. Once connection is made - all valid + * changes will be duplicated into it. + * Success of failure of actually making changes into the database can be + * tracked with `OnEditResult()` signal. + * + * This operation also returns immediate indication of whether it has + * failed *locally*. This can happen when trying to perform operation + * impossible for the local cache. For example, we cannot write any data at + * location "/a/b/c" for the JSON object "{"a":45.6}". + * If operation ended in failure locally, then change to database won't + * even be attempted. + * + * @param pointer Location into which to write the data. + * @param data Data to write into the connection. + * @return `true` on success and `false` on failure. `true` is required for + * the writing database request to be made. + */ +public final function bool WriteDataByJSON( + JSONPointer pointer, + AcediaObject data) +{ + if (pointer == none) { + return false; + } + if (localCache.Write(pointer, data)) + { + ModifyDataInDatabase(pointer, data, false); + return true; + } + return false; +} + +/** + * Increments given data into the `DBConnection` at the location defined by + * the given `JSONPointer`. + * + * If data was initialized with non-empty location for the root data, then + * actual location for incrementing data in the database is defined by + * appending given `pointer` to that root pointer. + * + * Data is actually always also incremented into the local cache, even when + * there is no connection to the database. Once connection is made - all valid + * changes will be duplicated into it. + * Success of failure of actually making changes into the database can be + * tracked with `OnEditResult()` signal. + * + * This operation also returns immediate indication of whether it has + * failed *locally*. This can happen when trying to perform operation + * impossible for the local cache. For example, we cannot increment any data at + * location "/a/b/c" for the JSON object "{"a":45.6}". + * If operation ended in failure locally, then change to database won't + * even be attempted. + * + * @param pointer Location at which to increment the data. + * @param data Data with which to increment value inside the connection. + * @return `true` on success and `false` on failure. `true` is required for + * the incrementing database request to be made. + */ +public final function bool IncrementDataByJSON( + JSONPointer pointer, + AcediaObject data) +{ + if (pointer == none) { + return false; + } + if (localCache.Increment(pointer, data)) + { + ModifyDataInDatabase(pointer, data, true); + return true; + } + return false; +} + +/** + * Removes data from the `DBConnection` at the location defined by the given + * `JSONPointer`. + * + * If data was initialized with non-empty location for the root data, then + * actual location at which to remove data in the database is defined by + * appending given `pointer` to that root pointer. + * + * Data is actually always also removed from the local cache, even when + * there is no connection to the database. Once connection is made - all valid + * changes will be duplicated into it. + * Success of failure of actually making changes into the database can be + * tracked with `OnEditResult()` signal. + * + * This operation also returns immediate indication of whether it has + * failed *locally*. + * If operation ended in failure locally, then change to database won't + * even be attempted. + * + * @param pointer Location at which to remove data. + * @return `true` on success and `false` on failure. `true` is required for + * the removal database request to be made. + */ +public final function bool RemoveDataByJSON(JSONPointer pointer) +{ + if (pointer == none) { + return false; + } + if (localCache.Remove(pointer)) + { + RemoveDataInDatabase(pointer); + return true; + } + return false; +} + +private final function ModifyDataInDatabase( + JSONPointer pointer, + AcediaObject data, + bool increment) +{ + local JSONPointer dataPointer; + + if (currentState != DBCS_Connected) { + return; + } + dataPointer = rootPointer.Copy(); + dataPointer.Append(pointer); + // `dataPointer` is consumed by `RegisterNextRequestID()` method + if (increment) + { + dbInstance + .IncrementData( + dataPointer, + data, + RegisterNextRequestID(dataPointer)) + .connect = EditDataHandler; + } + else + { + dbInstance + .WriteData(dataPointer, data, RegisterNextRequestID(dataPointer)) + .connect = EditDataHandler; + } +} + +private final function RemoveDataInDatabase(JSONPointer pointer) +{ + local JSONPointer dataPointer; + + if (currentState != DBCS_Connected) { + return; + } + dataPointer = rootPointer.Copy(); + dataPointer.Append(pointer); + // `dataPointer` is consumed by `RegisterNextRequestID()` method + dbInstance + .RemoveData(dataPointer, RegisterNextRequestID(dataPointer)) + .connect = EditDataHandler; +} + +/** + * Checks caller `DBConnection` was successfully initialized. + * + * @return `true` if caller `DBConnection` was initialized and `false` + * otherwise. + */ +public final function bool IsInitialized() +{ + return (dbInstance != none); +} + +/** + * Returns current state of the connection of `DBConnection` to the database + * it was initialized with. + * + * @see `OnStateChanged()` for more information about connection states. + * @return Current connection state. + */ +public final function DBConnectionState GetConnectionState() +{ + return currentState; +} + +/** + * Checks whether caller `DBConnection` is currently connected without errors + * to the database it was initialized with. + * + * @return `true` if caller `DBConnection` is connected to the database and + * `false` otherwise. + */ +public final function bool IsConnected() +{ + return (currentState == DBCS_Connected); +} + +/** + * Checks whether an error has occurred with connection to the database. + * + * `DBConnection` can get disconnected from database manually and without + * any errors, so, if you simply want to check whether connection exists, + * @see `IsConnected()` or @see `GetConnectionState()`. + * To obtain more detailed information @see `GetError()`. + * + * @return `true` if there were no error thus far and `false` otherwise. + */ +public final function bool IsOk() +{ + return (dbFailureReason == FDE_None); +} + +/** + * Returns error that has occurred during connection. + * + * @return Error that has occurred during connection to the database, + * `FDE_None` if there was no errors. + */ +public final function FaultyDatabaseError GetError() +{ + return dbFailureReason; +} + +private final function ChangeState(DBConnectionState newState) +{ + local DBConnectionState oldState; + + oldState = currentState; + currentState = newState; + onStateChangedSignal.Emit(self, oldState, newState); +} + +/** + * Attempts connection to the database caller `DBConnection` was initialized + * with. Result isn't immediate and can be tracked with `OnStateChanged()` + * signal. + * + * Connection checks whether data by the initialization address can be read and + * has proper type (by default JSON object, but JSON array can be used + * instead). + * + * Whether connection is successfully established isn't known at the moment + * this function returns. User `OnStateChanged()` to track that. + * + * @param expectArray Set this to `true` if the expected root value is + * JSON array. + */ +public final function Connect(optional bool expectArray) +{ + local Collection incrementObject; + + if (!IsInitialized()) return; + if (currentState != DBCS_Idle) return; + + if (expectArray) { + incrementObject = _.collections.EmptyArrayList(); + } + else { + incrementObject = _.collections.EmptyHashTable(); + } + dbInstance.IncrementData( + rootPointer, + incrementObject, + RegisterNextRequestID()).connect = IncrementCheckHandler; + incrementObject.FreeSelf(); + // Copy of the `rootPointer` is consumed by `RegisterNextRequestID()` + // method + dbInstance.ReadData(rootPointer,, RegisterNextRequestID(rootPointer.Copy())) + .connect = InitialLoadingHandler; + ChangeState(DBCS_Connecting); +} + +/** + * Disconnects `DBConnection` from its database, preventing its further + * updates. + * + * Database can only be disconnected if connection was at least initialized + * (state isn't `DBCS_Idle`) and no error has yet occurred (state isn't + * `DBCS_FaultyDatabase`). + * + * @return `true` if `DBConnection` was disconnected from the database and + * `false` otherwise (including if it already was disconnected). + */ +public final function bool Disconnect() +{ + if ( currentState != DBCS_FaultyDatabase + && currentState != DBCS_Idle + && currentState != DBCS_Disconnected) + { + ChangeState(DBCS_Disconnected); + return true; + } + return false; +} + +private final function int RegisterNextRequestID( + optional /*take*/ JSONPointer relativePointer) +{ + if (relativePointer != none) { + queuedPointers[queuedPointers.length] = relativePointer; + } + else { + queuedPointers[queuedPointers.length] = _.json.Pointer(); + } + requestIDs[requestIDs.length] = nextRequestID; + nextRequestID += 1; + return (nextRequestID - 1); +} + +private final function JSONPointer FetchRequestPointer(int requestID) +{ + local int i; + local JSONPointer result; + + while (i < requestIDs.length) + { + if (requestIDs[i] < requestID) + { + // We receive all requests in order, so if `requestID` is higher + // than IDs of some other requests - it means that they are older, + // lost requests + _.memory.Free(queuedPointers[i]); + queuedPointers.Remove(i, 1); + requestIDs.Remove(i, 1); + } + if (requestIDs[i] == requestID) + { + result = queuedPointers[i]; + queuedPointers.Remove(i, 1); + requestIDs.Remove(i, 1); + return result; + } + i += 1; + } + return none; +} + +private final function bool FetchIfRequestStillValid(int requestID) +{ + local JSONPointer result; + + result = FetchRequestPointer(requestID); + if (result != none) + { + _.memory.Free(result); + return true; + } + return false; +} + +private final function IncrementCheckHandler( + Database.DBQueryResult result, + Database source, + int requestID) +{ + if (!FetchIfRequestStillValid(requestID)) { + return; + } + // If we could successfully increment value with appropriate JSON value, + // then its type is correct + rootIsOfExpectedType = (result == DBR_Success); +} + +private final function InitialLoadingHandler( + Database.DBQueryResult result, + /*take*/ AcediaObject data, + Database source, + int requestID) +{ + local int i; + local array completedEdits; + + if (!FetchIfRequestStillValid(requestID)) + { + _.memory.Free(data); + return; + } + if (HandleInitializationError(result)) + { + _.memory.Free(data); + return; + } + completedEdits = localCache.SetRealData(data); + for (i = 0; i < completedEdits.length; i += 1) + { + if (completedEdits[i].successful) + { + if (completedEdits[i].type == DBCET_Remove) { + RemoveDataInDatabase(completedEdits[i].location); + } + else + { + ModifyDataInDatabase( + completedEdits[i].location, + completedEdits[i].data, + completedEdits[i].type == DBCET_Increment); + } + } + else { + onEditResultSignal.Emit(completedEdits[i].location, false); + } + _.memory.Free(completedEdits[i].location); + _.memory.Free(completedEdits[i].data); + } + _.memory.Free(data); + ChangeState(DBCS_Connected); +} + +// Return `true` if further initialization must be stopped. +private final function bool HandleInitializationError( + Database.DBQueryResult result) +{ + // Get disconnected before even response has even arrived + if (currentState == DBCS_Disconnected) { + return true; + } + if (currentState == DBCS_Connected) + { + _.logger.Auto(errDoubleInitialization).Arg(rootPointer.ToText()); + return true; + } + if (result == DBR_InvalidDatabase) + { + dbFailureReason = FDE_Unknown; + ChangeState(DBCS_FaultyDatabase); + return true; + } + if (result != DBR_Success) + { + dbFailureReason = FDE_CannotReadRootData; + ChangeState(DBCS_FaultyDatabase); + return true; + } + if (!rootIsOfExpectedType) + { + dbFailureReason = FDE_UnexpectedRootData; + ChangeState(DBCS_FaultyDatabase); + return true; + } + return false; +} + +private final function EditDataHandler( + Database.DBQueryResult result, + Database source, + int requestID) +{ + local JSONPointer relatedPointer; + + relatedPointer = FetchRequestPointer(requestID); + if (relatedPointer == none) { + return; + } + if (result == DBR_InvalidDatabase) + { + dbFailureReason = FDE_Unknown; + ChangeState(DBCS_FaultyDatabase); + relatedPointer.FreeSelf(); + return; + } + if (result == DBR_Success) { + onEditResultSignal.Emit(relatedPointer, true); + } + else { + onEditResultSignal.Emit(relatedPointer, false); + } + relatedPointer.FreeSelf(); +} + +defaultproperties +{ + errDoubleInitialization = (l=LOG_Error,m="`DBConnection` connected to \"%1\" was double-initialized. This SHOULD NOT happen. Please report this bug.") +} \ No newline at end of file diff --git a/sources/Data/Database/Connection/Events/DBConnection_EditResult_Signal.uc b/sources/Data/Database/Connection/Events/DBConnection_EditResult_Signal.uc new file mode 100644 index 0000000..8ec43fc --- /dev/null +++ b/sources/Data/Database/Connection/Events/DBConnection_EditResult_Signal.uc @@ -0,0 +1,40 @@ +/** + * Signal class for `DBConnections`'s `OnEditResult()` signal. + * Copyright 2023 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 DBConnection_EditResult_Signal extends Signal + dependson(DBConnection); + +public final function Emit(JSONPointer editLocation, bool isSuccessful) +{ + local Slot nextSlot; + StartIterating(); + nextSlot = GetNextSlot(); + while (nextSlot != none) + { + DBConnection_EditResult_Slot(nextSlot) + .connect(editLocation, isSuccessful); + nextSlot = GetNextSlot(); + } + CleanEmptySlots(); +} + +defaultproperties +{ + relatedSlotClass = class'DBConnection_EditResult_Slot' +} \ No newline at end of file diff --git a/sources/Data/Database/Connection/Events/DBConnection_EditResult_Slot.uc b/sources/Data/Database/Connection/Events/DBConnection_EditResult_Slot.uc new file mode 100644 index 0000000..6ffcbc9 --- /dev/null +++ b/sources/Data/Database/Connection/Events/DBConnection_EditResult_Slot.uc @@ -0,0 +1,41 @@ +/** + * Slot class for `DBConnections`'s `OnEditResult()` signal. + * Copyright 2023 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 DBConnection_EditResult_Slot extends Slot + dependson(DBConnection); + +delegate connect(JSONPointer editLocation, bool isSuccessful) +{ + DummyCall(); +} + +protected function Constructor() +{ + connect = none; +} + +protected function Finalizer() +{ + super.Finalizer(); + connect = none; +} + +defaultproperties +{ +} \ No newline at end of file diff --git a/sources/Data/Database/Connection/Events/DBConnection_StateChanged_Signal.uc b/sources/Data/Database/Connection/Events/DBConnection_StateChanged_Signal.uc new file mode 100644 index 0000000..a7147ae --- /dev/null +++ b/sources/Data/Database/Connection/Events/DBConnection_StateChanged_Signal.uc @@ -0,0 +1,43 @@ +/** + * Signal class for `DBConnections`'s `OnStatusChanged()` signal. + * Copyright 2023 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 DBConnection_StateChanged_Signal extends Signal + dependson(DBConnection); + +public final function Emit( + DBConnection instance, + DBConnection.DBConnectionState oldState, + DBConnection.DBConnectionState newState) +{ + local Slot nextSlot; + StartIterating(); + nextSlot = GetNextSlot(); + while (nextSlot != none) + { + DBConnection_StateChanged_Slot(nextSlot) + .connect(instance, oldState, newState); + nextSlot = GetNextSlot(); + } + CleanEmptySlots(); +} + +defaultproperties +{ + relatedSlotClass = class'DBConnection_StateChanged_Slot' +} \ No newline at end of file diff --git a/sources/Data/Database/Connection/Events/DBConnection_StateChanged_Slot.uc b/sources/Data/Database/Connection/Events/DBConnection_StateChanged_Slot.uc new file mode 100644 index 0000000..f0772e0 --- /dev/null +++ b/sources/Data/Database/Connection/Events/DBConnection_StateChanged_Slot.uc @@ -0,0 +1,44 @@ +/** + * Slot class for `DBConnections`'s `OnStatusChanged()` signal. + * Copyright 2023 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 DBConnection_StateChanged_Slot extends Slot + dependson(DBConnection); + +delegate connect( + DBConnection instance, + DBConnection.DBConnectionState oldState, + DBConnection.DBConnectionState newState) +{ + DummyCall(); +} + +protected function Constructor() +{ + connect = none; +} + +protected function Finalizer() +{ + super.Finalizer(); + connect = none; +} + +defaultproperties +{ +} \ No newline at end of file diff --git a/sources/Data/Database/Connection/Tests/TEST_DBConnection.uc b/sources/Data/Database/Connection/Tests/TEST_DBConnection.uc new file mode 100644 index 0000000..2918dd4 --- /dev/null +++ b/sources/Data/Database/Connection/Tests/TEST_DBConnection.uc @@ -0,0 +1,389 @@ +/** + * Set of tests for `DBConnection` and `DBCache` classes. + * Copyright 2023 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_DBConnection extends TestCase + abstract; + +var DBCache cache; + +protected static function TESTS() +{ + Context("Testing how `DBCache` handles data writes and reads when" + @ "the actual database's data is yet to be downloaded."); + Test_DBCache_BasicWriteRead_DuringLoading(); + Test_DBCache_AllOperations_DuringLoading(); + Context("Testing how `DBCache` handles applying pending writes after real" + @ "data was set."); + Test_DBCache_ApplyingPendingEdits(); + Test_DBCache_ApplyingPendingEdit_AllOperations(); + Context("Testing how `DBCache` handles data writes and reads when" + @ "the actual database's data is already setup."); + Test_DBCache_BasicWriteRead_AfterLoading(); + Test_DBCache_AllOperations_AfterLoading(); +} + +/* Creates following object: +{ + "A": "simpleValue", + "B": { + "A": [true, { + "A": "simpleValue", + "B": 11.12, + "": [true, null, "huh"] + }, "huh"], + "B": -13.95 + }, + "C": -5, + "D": [] +} */ +protected static function HashTable MakeTestJSONObject() +{ + local ArrayList outerArray, innerArray; + local HashTable result, outerObject, innerObject; + + innerArray = __().collections.EmptyArrayList(); + innerArray.AddBool(true).AddItem(none).AddString("huh"); + innerObject = __().collections.EmptyHashTable(); + innerObject.SetString(P("A"), "simpleValue"); + innerObject.SetFloat(P("B"), 11.12).SetItem(P(""), innerArray); + outerArray = __().collections.EmptyArrayList(); + outerArray.AddBool(true).AddItem(innerObject).AddString("huh"); + outerObject = __().collections.EmptyHashTable(); + outerObject.SetItem(P("A"), outerArray).SetFloat(P("B"), -13.95); + result = __().collections.EmptyHashTable(); + result.SetString(P("A"), "simpleValue"); + result.SetItem(P("B"), outerObject); + result.SetInt(P("C"), -5); + result.SetItem(P("D"), __().collections.EmptyArrayList()); + return result; +} + +protected static function CheckLocation(string pointer, string expectedResult) +{ + local AcediaObject value; + + value = default.cache + .Read(__().json.Pointer(__().text.FromString(pointer))); + if (BaseText(value) != none) + { + TEST_ExpectTrue( + __().json.Print(value).ToString() + == ("\"" $ expectedResult $ "\"")); + } + else { + TEST_ExpectTrue(__().json.Print(value).ToString() == expectedResult); + } +} + +// This is a rather extensive and long test. +// Despite its length, I've chosen to not break it up into smaller parts, +// since the same value `default.cache` is changed and built up all the way +// through this method. +// Trying to break it up into smaller and simpler tests would only mean +// putting less stress of `DBCache` than we currently do. +protected static function Test_DBCache_BasicWriteRead_DuringLoading() +{ + local AcediaObject complexData; + + Issue("Simple write/read sequence not working correctly."); + default.cache = DBCache(__().memory.Allocate(class'DBCache')); + complexData = MakeTestJSONObject(); + default.cache.Write( + __().json.Pointer(P("/just/some/place/far/away")), + complexData); + default.cache.Write( + __().json.Pointer(P("/just/another/place/not/near")), + complexData); + CheckLocation("/just/another/place/not/near/A", "simpleValue"); + CheckLocation("/just/some/place/far/away/B/A/2", "huh"); + CheckLocation("/just/another/place/not/near/B/A/1//2", "huh"); + CheckLocation("/just/some/place/far/away/B/A/1/", "[true,null,\"huh\"]"); + + Issue("Data is read incorrectly after being overridden."); + default.cache.Write( + __().json.Pointer(P("/just/another/place/not/near/B/A/1//1")), + __().box.bool(false)); + CheckLocation( + "/just/another/place/not/near/B/A/1/", + "[true,false,\"huh\"]"); + default.cache.Write( + __().json.Pointer(P("/just/another/place/not/near/B/A")), + __().box.int(121)); + CheckLocation("/just/another/place/not/near/B/A", "121"); + default.cache.Write( + __().json.Pointer(P("/just/some/place/far/away/B/A/1/B")), + complexData); + CheckLocation("/just/some/place/far/away/B/A/1/B/B/A/0", "true"); + default.cache.Write( + __().json.Pointer( + P("/just/some/place/far/away/C/inside_the_number!/hey")), + __().box.float(1.1)); + CheckLocation("/just/some/place/far/away/C/inside_the_number!/hey", "null"); + default.cache.Write( + __().json.Pointer(P("/just/some/place/far/away/D/7/hey")), + __().box.int(-345)); + CheckLocation("/just/some/place/far/away/D", "[]"); + CheckLocation("/just/some/place/far/away/D/7/hey", "-345"); + default.cache.Write( + __().json.Pointer( + P("/just/some/place/far/away/D/inside_the_array!/hey")), + __().box.float(1.1)); + CheckLocation("/just/some/place/far/away/D/inside_the_array!/hey", "null"); + CheckLocation("/just/some/place/far/away/D", "[]"); + default.cache.Write( + __().json.Pointer(P("/just/some/place/far/away/D/-")), + __().box.bool(true)); + CheckLocation("/just/some/place/far/away/D", "[true]"); + default.cache.Write( + __().json.Pointer(P("/just/some/place/far/away/D/7")), + __().box.bool(true)); + CheckLocation( + "/just/some/place/far/away/D", + "[true,null,null,null,null,null,null,true]"); + Issue("Writing at the end of a JSON array several times doesn't correctly" + @ "keep all values."); + default.cache.Write( + __().json.Pointer(P("/just/some/place/far/away/D/-")), + __().box.int(13524)); + default.cache.Write( + __().json.Pointer(P("/just/some/place/far/away/D/-")), none); + default.cache.Write( + __().json.Pointer(P("/just/some/place/far/away/D/-")), + __().box.int(121)); + CheckLocation( + "/just/some/place/far/away/D", + "[true,null,null,null,null,null,null,true,13524,null,121]"); +} + +protected static function Test_DBCache_AllOperations_DuringLoading() +{ + local AcediaObject complexData; + local ArrayList newArrayData; + + newArrayData = __().collections.EmptyArrayList(); + newArrayData = newArrayData.AddInt(45); + newArrayData = newArrayData.AddItem(none); + newArrayData = newArrayData.AddString("lol"); + + Issue("Increment/remove/read sequence not working correctly."); + default.cache = DBCache(__().memory.Allocate(class'DBCache')); + complexData = MakeTestJSONObject(); + default.cache.Increment(__().json.Pointer(P("")), complexData); + default.cache.Increment(__().json.Pointer(P("/B/A/1/A")), + __().text.FromString("oi")); + default.cache.Remove(__().json.Pointer(P("/B/A/1/B"))); + default.cache.Remove(__().json.Pointer(P("/B/A/1/"))); + default.cache.Increment(__().json.Pointer(P("/B/A")), newArrayData); + default.cache.Increment(__().json.Pointer(P("/C")), __().box.float(34.5)); + default.cache.Increment(__().json.Pointer(P("/C")), __().box.bool(true)); + default.cache.Increment(__().json.Pointer(P("/D")), newArrayData); + default.cache.Increment(__().json.Pointer(P("/D")), newArrayData); + default.cache.Increment(__().json.Pointer(P("/D")), newArrayData); + default.cache.Increment(__().json.Pointer(P("/B/A/1/A")), + __().text.FromString("! Yeah!")); + // Override all increments! + default.cache.Write(__().json.Pointer(P("/D")), newArrayData); + CheckLocation("/B/A/1/A", "simpleValueoi! Yeah!"); + CheckLocation("/B/A", + "[true,{\"A\":\"simpleValueoi! Yeah!\"},\"huh\",45,null,\"lol\"]"); + CheckLocation("/C", "29.5"); + CheckLocation("/D", "[45,null,\"lol\"]"); +} + +protected static function Test_DBCache_ApplyingPendingEdits() +{ + SubTest_DBCache_ApplyingPendingEdits_Simple(); + SubTest_DBCache_ApplyingPendingEdits_Complex(); +} + +protected static function SubTest_DBCache_ApplyingPendingEdits_Simple() +{ + local int i; + local array result; + + Issue("Pending writes successfully apply in simple JSON object case."); + default.cache = DBCache(__().memory.Allocate(class'DBCache')); + default.cache.Write( + __().json.Pointer(P("")), + __().collections.EmptyHashTable()); + default.cache.Write(__().json.Pointer(P("/hey")), __().box.int(476)); + default.cache.Write(__().json.Pointer(P("/hope")), none); + default.cache.Write(__().json.Pointer(P("/-")), __().ref.float(324.3)); + result = default.cache.SetRealData(__().text.FromString("woah")); + for (i = 0; i < result.length; i += 1) { + TEST_ExpectTrue(result[i].successful); + } + CheckLocation("/hey", "476"); + CheckLocation("/hope", "null"); + CheckLocation("/-", "324.3"); + + Issue("Pending don't fail in a simple case of writing into JSON string."); + default.cache = DBCache(__().memory.Allocate(class'DBCache')); + default.cache.Write(__().json.Pointer(P("/hey")), __().box.int(476)); + default.cache.Write(__().json.Pointer(P("/hope")), none); + default.cache.Write(__().json.Pointer(P("/-")), __().ref.float(324.3)); + result = default.cache.SetRealData(__().text.FromString("woah")); + for (i = 0; i < result.length; i += 1) { + TEST_ExpectFalse(result[i].successful); + } + CheckLocation("/hey", "null"); + CheckLocation("/hope", "null"); + CheckLocation("/-", "null"); +} + +protected static function SubTest_DBCache_ApplyingPendingEdits_Complex() +{ + local array result; + + Issue("Pending writes incorrectly apply in complex case."); + default.cache = DBCache(__().memory.Allocate(class'DBCache')); + default.cache.Write(__().json.Pointer(P("/B/A/1/B")), __().box.int(777)); + default.cache.Write(__().json.Pointer(P("/B/A/-")), __().box.bool(true)); + default.cache.Write(__().json.Pointer(P("/D/5")), __().box.float(1.1)); + default.cache.Write( + __().json.Pointer(P("/new")), + __().collections.EmptyHashTable()); + default.cache.Write( + __().json.Pointer(P("/new/sub")), + __().text.FromString("!SubString!")); + default.cache.Write( + __().json.Pointer(P("/D/impossiburu")), + __().text.FromString("!SubString!")); + result = default.cache.SetRealData(MakeTestJSONObject()); + CheckLocation("/B/A/1/B", "777"); + CheckLocation("/B/A/3", "true"); + CheckLocation("/D/5", "1.1"); + CheckLocation("/D/2", "null"); + CheckLocation("/new", "{\"sub\":\"!SubString!\"}"); + TEST_ExpectTrue(result[0].successful); + TEST_ExpectTrue(result[1].successful); + TEST_ExpectTrue(result[2].successful); + TEST_ExpectTrue(result[3].successful); + TEST_ExpectTrue(result[4].successful); + TEST_ExpectFalse(result[5].successful); +} + +protected static function Test_DBCache_ApplyingPendingEdit_AllOperations() +{ + local ArrayList newArrayData; + local array result; + + newArrayData = __().collections.EmptyArrayList(); + newArrayData = newArrayData.AddInt(45).AddItem(none).AddString("lol"); + + Issue("Pending increments and removals incorrectly apply in complex case."); + default.cache = DBCache(__().memory.Allocate(class'DBCache')); + default.cache.Increment(__().json.Pointer(P("/B/A/1/A")), + __().text.FromString("oi")); + default.cache.Remove(__().json.Pointer(P("/B/A/1/B"))); + default.cache.Remove(__().json.Pointer(P("/B/A/1/"))); + default.cache.Increment(__().json.Pointer(P("/B/A")), newArrayData); + default.cache.Increment(__().json.Pointer(P("/C")), __().box.float(34.5)); + default.cache.Increment(__().json.Pointer(P("/C")), __().box.bool(true)); + default.cache.Increment(__().json.Pointer(P("/D")), newArrayData); + default.cache.Increment(__().json.Pointer(P("/D")), newArrayData); + default.cache.Remove(__().json.Pointer(P("/B/A/Y"))); + default.cache.Increment(__().json.Pointer(P("/B/A/1/A")), + __().text.FromString("! Yeah!")); + default.cache.Write(__().json.Pointer(P("/D")), newArrayData); + result = default.cache.SetRealData(MakeTestJSONObject()); + TEST_ExpectTrue(result.length == 9); + TEST_ExpectTrue(result[0].successful); + TEST_ExpectTrue(result[1].successful); + TEST_ExpectTrue(result[2].successful); + TEST_ExpectTrue(result[3].successful); + TEST_ExpectTrue(result[4].successful); + TEST_ExpectFalse(result[5].successful); + TEST_ExpectFalse(result[6].successful); + TEST_ExpectTrue(result[7].successful); + TEST_ExpectTrue(result[8].successful); +} + +protected static function Test_DBCache_BasicWriteRead_AfterLoading() +{ + local AcediaObject complexData; + + Issue("Simple write/read sequence not working."); + default.cache = DBCache(__().memory.Allocate(class'DBCache')); + complexData = MakeTestJSONObject(); + TEST_ExpectTrue(default.cache.SetRealData(complexData).length == 0); + default.cache.Write(__().json.Pointer(P("/B/A/1/B")), __().box.int(777)); + default.cache.Write(__().json.Pointer(P("/B/A/-")), __().box.bool(true)); + default.cache.Write(__().json.Pointer(P("/D/5")), __().box.float(1.1)); + default.cache.Write( + __().json.Pointer(P("/new")), + __().collections.EmptyHashTable()); + default.cache.Write( + __().json.Pointer(P("/new/sub")), + __().text.FromString("!SubString!")); + default.cache.Write( + __().json.Pointer(P("/D/impossiburu")), + __().text.FromString("!SubString!")); + CheckLocation("/B/A/1/B", "777"); + CheckLocation("/B/A/3", "true"); + CheckLocation("/D/5", "1.1"); + CheckLocation("/new", "{\"sub\":\"!SubString!\"}"); + + Issue("Simple write/read are affecting data when they shouldn't."); + CheckLocation("/D/2", "null"); + default.cache.Write(__().json.Pointer(P("")), __().box.float(1.1)); + default.cache.Write(__().json.Pointer(P("/hm?")), __().box.float(2.2)); + CheckLocation("", "1.1"); +} + +protected static function Test_DBCache_AllOperations_AfterLoading() +{ + local ArrayList newArrayData; + + newArrayData = __().collections.EmptyArrayList(); + newArrayData = newArrayData.AddInt(45).AddItem(none).AddString("lol"); + + Issue("Increment/remove/read sequence not working correctly."); + default.cache = DBCache(__().memory.Allocate(class'DBCache')); + default.cache.SetRealData(MakeTestJSONObject()); + TEST_ExpectTrue(default.cache.Increment(__().json.Pointer(P("/B/A/1/A")), + __().text.FromString("oi"))); + TEST_ExpectTrue(default.cache.Remove(__().json.Pointer(P("/B/A/1/B")))); + TEST_ExpectTrue(default.cache.Remove(__().json.Pointer(P("/B/A/1/")))); + TEST_ExpectTrue(default.cache.Increment(__().json.Pointer(P("/B/A")), + newArrayData)); + TEST_ExpectTrue(default.cache.Increment(__().json.Pointer(P("/C")), + __().box.float(34.5))); + TEST_ExpectFalse(default.cache.Increment(__().json.Pointer(P("/C")), + __().box.bool(true))); + TEST_ExpectTrue(default.cache.Increment(__().json.Pointer(P("/D")), + newArrayData)); + TEST_ExpectTrue(default.cache.Increment(__().json.Pointer(P("/D")), + newArrayData)); + TEST_ExpectFalse(default.cache.Remove(__().json.Pointer(P("/B/A/Y")))); + TEST_ExpectTrue(default.cache.Increment(__().json.Pointer(P("/B/A/1/A")), + __().text.FromString("! Yeah!"))); + default.cache.Write(__().json.Pointer(P("/D")), newArrayData); + CheckLocation("/B/A/1/A", "simpleValueoi! Yeah!"); + CheckLocation("/B/A", + "[true,{\"A\":\"simpleValueoi! Yeah!\"},\"huh\",45,null,\"lol\"]"); + CheckLocation("/C", "29.5"); + CheckLocation("/D", "[45,null,\"lol\"]"); +} + +defaultproperties +{ + caseGroup = "Database" + caseName = "DBConnection related tests" +} \ No newline at end of file diff --git a/sources/Data/Database/DBAPI.uc b/sources/Data/Database/DBAPI.uc index 46f38b0..d176e08 100644 --- a/sources/Data/Database/DBAPI.uc +++ b/sources/Data/Database/DBAPI.uc @@ -111,6 +111,56 @@ public final function JSONPointer GetPointer(BaseText databaseLink) return result; } +/** + * Opens a new `DBConnection` to the data referred to by the database link. + * + * Opened `DBConnection` doesn't automatically start a connection, so you + * need to call its `Connect()` method. + * + * @param databaseLink Database link to the data we want to connect to. + * @return Initialized `DBConnection` in case given link is valid and `none` + * otherwise. + */ +public final function DBConnection OpenConnection(BaseText databaseLink) +{ + local DBConnection result; + local Parser parser; + local Database databaseToConnect; + local JSONPointer locationToConnect; + local MutableText databaseName, textPointer; + + if (databaseLink == none) { + return none; + } + parser = _.text.Parse(databaseLink); + // Only local DBs are supported for now! + // So just consume this prefix, if it's present. + parser.Match(P("[local]")).Confirm(); + textPointer = parser + .R() + .MUntil(databaseName, _.text.GetCharacter(":")) + .MatchS(":") + .GetRemainderM(); + if (parser.Ok()) + { + databaseToConnect = LoadLocal(databaseName); + locationToConnect = _.json.Pointer(textPointer); + result = DBConnection(_.memory.Allocate(class'DBConnection')); + result.Initialize(databaseToConnect, locationToConnect); + _.memory.Free(databaseToConnect); + _.memory.Free(locationToConnect); + } + parser.FreeSelf(); + _.memory.Free(databaseName); + _.memory.Free(textPointer); + if (result != none && !result.IsInitialized()) + { + result.FreeSelf(); + result = none; + } + return result; +} + /** * Creates new local database with name `databaseName`. * diff --git a/sources/Manifest.uc b/sources/Manifest.uc index 57aebf8..ecdae1c 100644 --- a/sources/Manifest.uc +++ b/sources/Manifest.uc @@ -53,7 +53,8 @@ defaultproperties testCases(25) = class'TEST_BigInt' testCases(26) = class'TEST_DatabaseCommon' testCases(27) = class'TEST_LocalDatabase' - testCases(28) = class'TEST_AcediaConfig' - testCases(29) = class'TEST_UTF8EncoderDecoder' - testCases(30) = class'TEST_AvariceStreamReader' + testCases(28) = class'TEST_DBConnection' + testCases(29) = class'TEST_AcediaConfig' + testCases(30) = class'TEST_UTF8EncoderDecoder' + testCases(31) = class'TEST_AvariceStreamReader' } \ No newline at end of file