Skip to content

Conversation

chrispader
Copy link
Contributor

@chrispader chrispader commented Mar 28, 2024

@tgolen

Details

When we batch queued changes in Onyx.merge and they contain (nested) null values, SQLite will never be able to apply the individual deltas. Therefore, when we pass null or a nested null in an object, SQLite will not delete the (nested) keys from storage.

Imagine having something like this:

const initialData = {
    a: 'a',
    b: 'b',
    c: {
        d: 'd',
        e: 'e',
    },
};
const change1 = {
    b: null
}
const change2 = null;
const change3 = {
    f: 'f',
    c: {
        g: 'g',
        h: 'h',
    },
};
const change4 = {
    c: {
        g: null,
    },
};
const changes = [change1, change2, change3, change4];

const batchedChanges = OnyxUtils.applyMerge(undefined, changes, false);

This will result in the following batchedChanges:

{
    f: 'f',
    c: {
        g: 'g',
    },
}

When we then apply these batched changes in the native storage layer (SQLite) with JSON_PATCH, the initial data will not be erased and keys "a", "b", "d" and "e" will not be removed either, because SQLite never applied the top-level and nested null's.

The expected value in storage after applying the batched changes should be:

{
    f: 'f',
    c: {
        h: 'h',
    },
}

Whereas the actual value will be:

{
    a: 'a',
    b: 'b',
    c: {
        d: 'd',
        e: 'e',
        h: 'h'
    },
    f: 'f'
};

Generally, when SQLite sees a top-level or nested null value, it will remove this (nested) key from the database. But since we already batched the changes beforehand and don't pass every delta from the queue individually.

Cases that will prevent SQLite from removing the key from storage:

  • any top-level null will not affect SQLite, if there's a change after, because the batchedChanges will get merged with the last values
  • any nested null before a top-level null will be cancelled out, since the top-level null just clears the batched changes

Notes multiple follow-up PRs

Creating a SQLite mock in react-native-quick-sqlite

I'm going to fix this issue in this PR. Additionally, i'm thinking about creating a in-memory or local SQLite mock and ship it with https://github.com/margelo/react-native-quick-sqlite. The mock could then either run in-memory directly - if possible - or we would need to spin up a local SQLite database.

Creating storage provider specific tests (with actual mocks)

With this mock we could add more native-specific tests in react-native-onyx and potentially improve other tests so that we test for all platforms.

Related Issues

GH_LINK

Automated Tests

Manual Tests

Author Checklist

  • I linked the correct issue in the ### Related Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android / native
    • Android / Chrome
    • iOS / native
    • iOS / Safari
    • MacOS / Chrome / Safari
    • MacOS / Desktop
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that the left part of a conditional rendering a React component is a boolean and NOT a string, e.g. myBool && <MyComponent />.
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • If we are not using the full Onyx data that we loaded, I've added the proper selector in order to ensure the component only re-renders when the data it is using changes
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR author checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
MacOS: Desktop

@tgolen
Copy link
Collaborator

tgolen commented Mar 28, 2024

Interesting issue! I'm not sure I fully understand it, even after reading it a couple of times, but as long as you have some tests that prove the bug and the fix, then I'm good with it.

As for the SQLite mock, I think it would be ideal to be able to spin up an actual SQLite database and run tests directly against it. That would give us extremely accurate and reliable tests. What would need to happen to have that?

@chrispader
Copy link
Contributor Author

chrispader commented Mar 29, 2024

Interesting issue! I'm not sure I fully understand it, even after reading it a couple of times, but as long as you have some tests that prove the bug and the fix, then I'm good with it.

I'm gonna further enhance the description. The thing is, that we're batching the changes from the mergeQueue here before even passing it to Storage.mergeItem and therefore to SQLite.

As for the SQLite mock, I think it would be ideal to be able to spin up an actual SQLite database and run tests directly against it. That would give us extremely accurate and reliable tests. What would need to happen to have that?

The idea about an in-memory SQLite database is as in https://www.sqlite.org/inmemorydb.html. It's possible to temporarily store the database only in-memory and this seems more than sufficient for testing/mocking purposes. If this is possible, i think we should prefer this, instead of relying on a local SQLite database and installation.

I'm gonna fix this actual issue before i potentially start work on a mock, as the mock isn't really high-priority right now. Basically we would just ship the mock in react-native-quick-sqlite and then we would need to setup the mock in jestSetup.js, same as the other mocks.

@chrispader chrispader changed the title [WIP] Fix: (Nested) null values will never be applied to SQLite when batched in Onyx [WIP] Fix: (Nested) null values will never be applied to SQLite when batched in Onyx Mar 29, 2024
@chrispader chrispader changed the title [WIP] Fix: (Nested) null values will never be applied to SQLite when batched in Onyx [WIP] Fix: (Nested) null values will never be applied to SQLite when batched in Onyx Mar 29, 2024
@tgolen
Copy link
Collaborator

tgolen commented Mar 29, 2024 via email

@chrispader
Copy link
Contributor Author

Ah, I see. So the in memory database would still use the exact same SQLite

interface? If so, then I agree that would be fine.

yes 👍

@chrispader chrispader marked this pull request as ready for review April 7, 2024 11:34
@chrispader chrispader requested a review from a team as a code owner April 7, 2024 11:34
@melvin-bot melvin-bot bot requested review from mountiny and removed request for a team April 7, 2024 11:34
@chrispader chrispader changed the title [WIP] Fix: (Nested) null values will never be applied to SQLite when batched in Onyx Fix: (Nested) null values will never be applied to SQLite when batched in Onyx Apr 7, 2024
@mountiny
Copy link
Contributor

mountiny commented Apr 7, 2024

@chrispader Lint is failing

@chrispader
Copy link
Contributor Author

chrispader commented Apr 8, 2024

@mountiny @tgolen i'm gonna finish up the PR Author checklist by the end of the day, but this is already working.

I've tested this thoroughly and it's working fine.

Basically how you can test this is either through this test branch that i created or in some other way. This branch basically just applies some delta changes to a key with Onyx.merge and prints out the value that's actually in Storage. To make this work, you will need to manually export the Storage from lib/storage in Onyx and use it within E/App

Copy link
Contributor

@mountiny mountiny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chrispader Changes look good to me, one comment, is there a way to test these in App now?

@chrispader
Copy link
Contributor Author

chrispader commented Apr 8, 2024

@mountiny addressed your comments.

@chrispader Changes look good to me, one comment, is there a way to test these in App now?

There isn't really a use-case in which you can test exactly this fixed behavior in E/App, since at the time this only occurs in SQLite on native platforms.

Therefore, in this test PR i do some Onyx operations and then fetch the value from storage directly instead of using something like withOnyx, Onyx.connect or Onyx.get, since for these functions the cache would be used first...

mountiny
mountiny previously approved these changes Apr 8, 2024
Copy link
Contributor

@mountiny mountiny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chrispader thanks! I have spotted one little typo before merging then

Co-authored-by: Vit Horacek <[email protected]>
@chrispader chrispader requested a review from mountiny April 8, 2024 21:40
Copy link
Contributor

@mountiny mountiny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

@mountiny mountiny merged commit f820e33 into Expensify:main Apr 9, 2024
Copy link
Contributor

github-actions bot commented Apr 9, 2024

🚀Published to npm in v2.0.27

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants