/**
* 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.")
}