/** * 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 BaseJSONPointer 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(BaseJSONPointer 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( BaseJSONPointer 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( BaseJSONPointer 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(BaseJSONPointer pointer) { if (pointer == none) { return false; } if (localCache.Remove(pointer)) { RemoveDataInDatabase(pointer); return true; } return false; } private final function ModifyDataInDatabase( BaseJSONPointer pointer, AcediaObject data, bool increment) { local MutableJSONPointer dataPointer; if (currentState != DBCS_Connected) { return; } dataPointer = rootPointer.MutableCopy(); dataPointer.Append(pointer); // `dataPointer` is consumed by `RegisterNextRequestID()` method if (increment) { dbInstance .IncrementData( dataPointer, data, RegisterNextRequestID(/*take*/ dataPointer.Copy())) .connect = EditDataHandler; _.memory.Free(dataPointer); } else { dbInstance .WriteData(dataPointer, data, RegisterNextRequestID(/*take*/ dataPointer.Copy())) .connect = EditDataHandler; _.memory.Free(dataPointer); } } private final function RemoveDataInDatabase(BaseJSONPointer pointer) { local MutableJSONPointer dataPointer; if (currentState != DBCS_Connected) { return; } dataPointer = rootPointer.MutableCopy(); dataPointer.Append(pointer); // `dataPointer` is consumed by `RegisterNextRequestID()` method dbInstance .RemoveData(dataPointer, RegisterNextRequestID(/*take*/ dataPointer.Copy())) .connect = EditDataHandler; _.memory.Free(dataPointer); } /** * 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.") }