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