Skip to content
Open
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
7 changes: 4 additions & 3 deletions lib/OnyxMerge/index.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ const applyMerge: ApplyMerge = <TKey extends OnyxKey, TValue extends OnyxInput<T
return Promise.resolve({mergedValue});
}

// For native platforms we use `mergeItem` that will take advantage of JSON_PATCH and JSON_REPLACE SQL operations to
// merge the object in a performant way.
return Storage.mergeItem(key, batchedChanges as OnyxValue<TKey>, replaceNullPatches).then(() => ({
// Delta writes retain nulls on insert and old fields after a whole-value reset.
const storagePromise = baseValue === undefined ? Storage.setItem(key, mergedValue as OnyxValue<TKey>) : Storage.mergeItem(key, batchedChanges as OnyxValue<TKey>, replaceNullPatches);

return storagePromise.then(() => ({
mergedValue,
}));
};
Expand Down
131 changes: 131 additions & 0 deletions tests/unit/OnyxMergeNativeTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/** Integration coverage for native Onyx.merge using NitroSQLite's Node mock. */
import type * as NitroSQLiteMock from 'react-native-nitro-sqlite/mock';
import Onyx from '../../lib';
import type {Connection} from '../../lib/Onyx';
import Storage from '../../lib/storage';
import SQLiteProvider from '../../lib/storage/providers/SQLiteProvider';

jest.unmock('../../lib/storage');
jest.mock('../../lib/storage/platforms', () => jest.requireActual('../../lib/storage/platforms/index.native'));
// Override the web implementation selected by the shared Jest configuration for this suite only.
jest.mock('../../lib/OnyxMerge', () => jest.requireActual('../../lib/OnyxMerge/index.native'));
jest.mock('react-native-nitro-sqlite', () => jest.requireActual('react-native-nitro-sqlite/mock'));

const {resetAllDatabases} = jest.requireActual<typeof NitroSQLiteMock>('react-native-nitro-sqlite/mock');

const ONYXKEYS = {
TEST_KEY: 'test',
RAM_ONLY_KEY: 'ramOnlyKey',
};

describe('Native OnyxMerge with SQLite storage', () => {
let connection: Connection | undefined;

beforeAll(() => {
Onyx.init({
keys: ONYXKEYS,
ramOnlyKeys: [ONYXKEYS.RAM_ONLY_KEY],
shouldSyncMultipleInstances: false,
enableDevTools: false,
});
expect(Storage.getStorageProvider()).toBe(SQLiteProvider);
});

beforeEach(async () => {
await Onyx.clear();
});

afterEach(() => {
connection?.unsubscribe();
connection = undefined;
jest.restoreAllMocks();
});

afterAll(async () => {
await Onyx.clear();
resetAllDatabases();
});

it.each([
Comment thread
chrispader marked this conversation as resolved.
Resolved
{
name: 'remove top-level and nested null properties from an existing value',
initialValue: {keep: true, remove: true, nested: {keep: true, remove: true}},
changes: [{remove: null, nested: {remove: null}}, {nested: {added: 'value'}}],
expected: {keep: true, nested: {keep: true, added: 'value'}},
},
{
name: 'remove nested null properties when inserting a new value',
initialValue: undefined,
changes: [{remove: null, nested: {keep: true, remove: null}}, {nested: {added: 'value'}}],
expected: {nested: {keep: true, added: 'value'}},
},
{
name: 'discard the existing value after a top-level null in a batch',
initialValue: {old: true, nested: {old: true}},
changes: [{ignored: true}, null, {added: true, nested: {keep: true, remove: true}}, {nested: {remove: null}}],
expected: {added: true, nested: {keep: true}},
},
{
name: 'replace a nested object after null without retaining its old properties',
initialValue: {keep: true, nested: {old: true, deeper: {old: true}}},
changes: [{nested: null}, {nested: {deeper: {added: true}}}],
expected: {keep: true, nested: {deeper: {added: true}}},
},
{
name: 'replace arrays while merging adjacent objects',
initialValue: {items: ['old'], nested: {keep: true}},
changes: [{items: ['first'], nested: {added: true}}, {items: ['last']}],
expected: {items: ['last'], nested: {keep: true, added: true}},
},
])('should $name', async ({initialValue, changes, expected}) => {
if (initialValue !== undefined) {
await Onyx.set(ONYXKEYS.TEST_KEY, initialValue);
}

let subscriberValue: unknown;
connection = Onyx.connect({
key: ONYXKEYS.TEST_KEY,
callback: (value) => {
subscriberValue = value;
},
});
await Promise.all(changes.map((change) => Onyx.merge(ONYXKEYS.TEST_KEY, change)));

expect(subscriberValue).toEqual(expected);
expect(await Onyx.get(ONYXKEYS.TEST_KEY)).toEqual(expected);
expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toEqual(expected);
});

it('should batch changes into one SQLite delta merge for an existing value', async () => {
await Onyx.set(ONYXKEYS.TEST_KEY, {keep: true});
const mergeItem = jest.spyOn(SQLiteProvider, 'mergeItem');

await Promise.all([Onyx.merge(ONYXKEYS.TEST_KEY, {first: true}), Onyx.merge(ONYXKEYS.TEST_KEY, {second: true})]);

expect(mergeItem).toHaveBeenCalledTimes(1);
expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toEqual({keep: true, first: true, second: true});
});

it('should skip persistence when the merged value is unchanged', async () => {
const value = {nested: {keep: true}};
await Onyx.set(ONYXKEYS.TEST_KEY, value);
const mergeItem = jest.spyOn(SQLiteProvider, 'mergeItem');

await Onyx.merge(ONYXKEYS.TEST_KEY, value);

expect(mergeItem).not.toHaveBeenCalled();
expect(await Onyx.get(ONYXKEYS.TEST_KEY)).toEqual(value);
expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toEqual(value);
});

it('should keep RAM-only merges out of SQLite', async () => {
const value = {nested: {keep: true}};
const mergeItem = jest.spyOn(SQLiteProvider, 'mergeItem');

await Onyx.merge(ONYXKEYS.RAM_ONLY_KEY, value);

expect(mergeItem).not.toHaveBeenCalled();
expect(await Onyx.get(ONYXKEYS.RAM_ONLY_KEY)).toEqual(value);
expect(await SQLiteProvider.getItem(ONYXKEYS.RAM_ONLY_KEY)).toBeNull();
});
});
6 changes: 3 additions & 3 deletions tests/unit/storage/providers/SQLiteProviderTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ describe('SQLiteProvider', () => {
it('does not block later reads after a failed write', async () => {
getOpenedConnection(0).execute(`CREATE TRIGGER reject_write BEFORE INSERT ON keyvaluepairs
BEGIN SELECT RAISE(ABORT, 'rejected write'); END;`);
await expect(SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'value')).rejects.toThrow('rejected write');
await expect(SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'value')).rejects.toMatchObject({message: 'rejected write'});
await expect(SQLiteProvider.getAllKeys()).resolves.toEqual([]);
});
});
Expand Down Expand Up @@ -295,7 +295,7 @@ describe('SQLiteProvider', () => {
[ONYXKEYS.TEST_KEY, 'first'],
[ONYXKEYS.TEST_KEY_2, 'second'],
]),
).rejects.toThrow('rejected insert');
).rejects.toMatchObject({message: 'rejected insert'});

// Then the batch is atomic and its failure does not block subsequent reads or writes.
expect(await SQLiteProvider.getAllKeys()).toEqual([]);
Expand Down Expand Up @@ -671,7 +671,7 @@ describe('SQLiteProvider', () => {
const keys = entries.map(([key]) => key);

// When a later delete fails after the first chunk has executed.
await expect(SQLiteProvider.removeItems(keys)).rejects.toThrow('rejected delete');
await expect(SQLiteProvider.removeItems(keys)).rejects.toMatchObject({message: 'rejected delete'});

// Then the entire batch rolls back and the writer remains usable.
expect(await SQLiteProvider.multiGet(keys)).toEqual(expect.arrayContaining(entries));
Expand Down
Loading