Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add some basic runtime type validation in Onyx #532

Merged
merged 7 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions lib/Onyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
OnyxValue,
} from './types';
import OnyxUtils from './OnyxUtils';
import logMessages from './logMessages';

// Keeps track of the last connectionID that was used so we can keep incrementing it
let lastConnectionID = 0;
Expand Down Expand Up @@ -208,6 +209,14 @@ function disconnect(connectionID: number, keyToRemoveFromEvictionBlocklist?: Ony
* @param value value to store
*/
function set<TKey extends OnyxKey>(key: TKey, value: OnyxEntry<KeyValueMapping[TKey]>): Promise<void> {
// check if the value is compatible with the existing value in the storage
const existingValue = cache.getValue(key, false);
const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(value, existingValue);
if (!isCompatible) {
Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'set', existingValueType, newValueType));
return Promise.resolve();
}

// If the value is null, we remove the key from storage
const {value: valueAfterRemoving, wasRemoved} = OnyxUtils.removeNullValues(key, value);
const valueWithoutNullValues = valueAfterRemoving as OnyxValue<TKey>;
Expand Down Expand Up @@ -307,7 +316,18 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxEntry<NullishDeep<K
try {
// We first only merge the changes, so we can provide these to the native implementation (SQLite uses only delta changes in "JSON_PATCH" to merge)
// We don't want to remove null values from the "batchedDeltaChanges", because SQLite uses them to remove keys from storage natively.
const batchedDeltaChanges = OnyxUtils.applyMerge(undefined, mergeQueue[key], false);
const validChanges = mergeQueue[key].filter((change) => {
const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(change, existingValue);
if (!isCompatible) {
Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'merge', existingValueType, newValueType));
}
return isCompatible;
});

if (!validChanges.length) {
return undefined;
}
const batchedDeltaChanges = OnyxUtils.applyMerge(undefined, validChanges, false);

// Case (1): When there is no existing value in storage, we want to set the value instead of merge it.
// Case (2): The presence of a top-level `null` in the merge queue instructs us to drop the whole existing value.
Expand Down Expand Up @@ -406,9 +426,17 @@ function mergeCollection<TKey extends CollectionKeyBase, TMap>(collectionKey: TK
});

const existingKeys = keys.filter((key) => persistedKeys.has(key));

const cachedCollectionForExistingKeys = OnyxUtils.getCachedCollection(collectionKey, existingKeys);

const newKeys = keys.filter((key) => !persistedKeys.has(key));

const existingKeyCollection = existingKeys.reduce((obj: NullableKeyValueMapping, key) => {
const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(mergedCollection[key], cachedCollectionForExistingKeys[key]);
if (!isCompatible) {
Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'mergeCollection', existingValueType, newValueType));
return obj;
}
// eslint-disable-next-line no-param-reassign
obj[key] = mergedCollection[key];
return obj;
Expand Down Expand Up @@ -441,11 +469,14 @@ function mergeCollection<TKey extends CollectionKeyBase, TMap>(collectionKey: TK
promises.push(Storage.multiSet(keyValuePairsForNewCollection));
}

// finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates
const finalMergedCollection = {...existingKeyCollection, ...newCollection};

// Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache
// and update all subscribers
const promiseUpdate = Promise.all(existingKeys.map(OnyxUtils.get)).then(() => {
cache.merge(mergedCollection);
return OnyxUtils.scheduleNotifyCollectionSubscribers(collectionKey, mergedCollection);
cache.merge(finalMergedCollection);
return OnyxUtils.scheduleNotifyCollectionSubscribers(collectionKey, finalMergedCollection);
});

return Promise.all(promises)
Expand Down
6 changes: 3 additions & 3 deletions lib/OnyxUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,10 +401,10 @@ function addAllSafeEvictionKeysToRecentlyAccessedList(): Promise<void> {
});
}

function getCachedCollection<TKey extends CollectionKeyBase>(collectionKey: TKey): NonNullable<OnyxCollection<KeyValueMapping[TKey]>> {
const collectionMemberKeys = Array.from(cache.getAllKeys()).filter((storedKey) => isCollectionMemberKey(collectionKey, storedKey));
function getCachedCollection<TKey extends CollectionKeyBase>(collectionKey: TKey, collectionMemberKeys?: string[]): NonNullable<OnyxCollection<KeyValueMapping[TKey]>> {
const resolvedCollectionMemberKeys = collectionMemberKeys || Array.from(cache.getAllKeys()).filter((storedKey) => isCollectionMemberKey(collectionKey, storedKey));

return collectionMemberKeys.reduce((prev: NonNullable<OnyxCollection<KeyValueMapping[TKey]>>, key) => {
return resolvedCollectionMemberKeys.reduce((prev: NonNullable<OnyxCollection<KeyValueMapping[TKey]>>, key) => {
const cachedValue = cache.getValue(key);
if (!cachedValue) {
return prev;
Expand Down
7 changes: 7 additions & 0 deletions lib/logMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const logMessages = {
incompatibleUpdateAlert: (key: string, operation: string, existingValueType?: string, newValueType?: string) => {
return `Warning: Trying to apply "${operation}" with ${newValueType ?? 'unknown'} type to ${existingValueType ?? 'unknown'} type in the key "${key}"`;
},
};

export default logMessages;
23 changes: 22 additions & 1 deletion lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,25 @@ function formatActionName(method: string, key?: OnyxKey): string {
return key ? `${method.toUpperCase()}/${key}` : method.toUpperCase();
}

export default {isEmptyObject, fastMerge, formatActionName, removeNestedNullValues};
/** validate that the update and the existing value are compatible */
function checkCompatibilityWithExistingValue(value: unknown, existingValue: unknown): {isCompatible: boolean; existingValueType?: string; newValueType?: string} {
if (!existingValue || !value) {
return {
isCompatible: true,
};
}
const existingValueType = Array.isArray(existingValue) ? 'array' : 'non-array';
dominictb marked this conversation as resolved.
Show resolved Hide resolved
const newValueType = Array.isArray(value) ? 'array' : 'non-array';
if (existingValueType !== newValueType) {
return {
isCompatible: false,
existingValueType,
newValueType,
};
}
return {
dominictb marked this conversation as resolved.
Show resolved Hide resolved
isCompatible: true,
};
}

export default {isEmptyObject, fastMerge, formatActionName, removeNestedNullValues, checkCompatibilityWithExistingValue};
43 changes: 43 additions & 0 deletions tests/unit/onyxTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@ describe('Onyx', () => {
});
});

it('should not set the key if the value is incompatible (array vs object)', () => {
let testKeyValue;

connectionID = Onyx.connect({
key: ONYX_KEYS.TEST_KEY,
initWithStoredValues: false,
callback: (value) => {
testKeyValue = value;
},
});

return Onyx.set(ONYX_KEYS.TEST_KEY, ['test'])
.then(() => {
expect(testKeyValue).toStrictEqual(['test']);
return Onyx.set(ONYX_KEYS.TEST_KEY, {test: 'test'});
})
.then(() => {
expect(testKeyValue).toStrictEqual(['test']);
});
});

it('should merge an object with another object', () => {
let testKeyValue;

Expand All @@ -93,6 +114,27 @@ describe('Onyx', () => {
});
});

it('should not merge if the value is incompatible (array vs object)', () => {
let testKeyValue;

connectionID = Onyx.connect({
key: ONYX_KEYS.TEST_KEY,
initWithStoredValues: false,
callback: (value) => {
testKeyValue = value;
},
});

return Onyx.merge(ONYX_KEYS.TEST_KEY, ['test'])
.then(() => {
expect(testKeyValue).toStrictEqual(['test']);
return Onyx.merge(ONYX_KEYS.TEST_KEY, {test2: 'test2'});
})
.then(() => {
expect(testKeyValue).toStrictEqual(['test']);
});
});

it('should notify subscribers when data has been cleared', () => {
let testKeyValue;
connectionID = Onyx.connect({
Expand Down Expand Up @@ -389,6 +431,7 @@ describe('Onyx', () => {
ID: 234,
value: 'four',
},
test_3: ['abc', 'xyz'], // This shouldn't be merged since it's an array, and the original value is an object {ID, value}
test_4: {
ID: 456,
value: 'two',
Expand Down
Loading