/** * 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(BaseJSONPointer)` and * `Write(BaseJSONPointer, 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(BaseJSONPointer 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(BaseJSONPointer 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(BaseJSONPointer 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(BaseJSONPointer 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; } /** * 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(BaseJSONPointer 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( BaseJSONPointer 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, BaseJSONPointer 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, BaseJSONPointer location, AcediaObject value, DBCacheEditType editType) { local bool success; local Text key; local ArrayList arrayCollection; local HashTable objectCollection; local Collection innerCollection; local MutableJSONPointer 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.MutableCopy(); 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.PeekNumeric()); } 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( BaseJSONPointer 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(BaseJSONPointer 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, BaseJSONPointer 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, BaseJSONPointer 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 { }