realm / realm-js

Realm is a mobile database: an alternative to SQLite & key-value stores
https://realm.io
Apache License 2.0
5.69k stars 563 forks source link

Data Loss in Nested Objects When Using Spread Operator for Update in Realm JS #6758

Closed tharwi closed 10 hours ago

tharwi commented 3 weeks ago

How frequently does the bug occur?

Always

Description

I am experiencing data loss in nested objects when updating an object in Realm JS using the spread operator. Specifically, after the update, one of my nested lists (trackingDataList) gets reset to an empty list.

Realm models

class TrackingData extends Realm.Object {
    id!: number;
    progress!: number;

    static schema: Realm.ObjectSchema = {
        name: 'TrackingData',
        primaryKey: 'id',
        properties: {
            id: 'int',
            progress: 'int',
        },
    };
}

export default class Profile extends Realm.Object {
    id!: string;
    attemptID!: number;
    lastAccessDate!: Date | null;
    trackingDataList!: TrackingData[];
    sortOrder!: number;
    from!: Date;
    userId!: string;

    static schema: Realm.ObjectSchema = {
        name: 'Profile',
        primaryKey: 'id',

        properties: {
            id: 'string',
            attemptID: 'int',
            lastAccessDate: {type: 'date', optional: true},
            trackingDataList: 'TrackingData[]',
            sortOrder: 'int',
            from: 'date',
            userId: 'string',
        },
    };
}

Code Sample

const updatedProfile = { ...currentProfile, lastAccessDate: Moment.utc().format() };

realm().write(() => {
  realm().create('Profile', updatedProfile, 'modified');
});

Before Update:

{
  "id": "12345_2_2",
  "attemptID": 2,
  "lastAccessDate": null,
  "trackingDataList": [
    {
      "id": 1,
      "progress": 50
    }
  ],
  "sortOrder": 1,
  "from": "2024-06-10T10:27:02.909Z",
  "userId": "12345"
}

After Update

{
  "id": "12345_2_2",
  "attemptID": 2,
  "lastAccessDate": "2024-06-25T16:26:43Z",
  "trackingDataList": [],
  "sortOrder": 1,
  "from": "2024-06-10T10:27:02.909Z",
  "userId": "12345"
}

Stacktrace & log output

N/A

Can you reproduce the bug?

Always

Reproduction Steps

  1. Create an object in Realm with nested objects/lists.
  2. Update the object using the spread operator and change a single key.
  3. Observe that some nested lists are reset or missing after the update.

Version

12.10.0

What services are you using?

Local Database only

Are you using encryption?

No

Platform OS and version(s)

iOS 17.2

Build environment

react-native: 0.74.2 node: v18.17.1

Cocoapods version

1.15.2

sync-by-unito[bot] commented 3 weeks ago

➤ PM Bot commented:

Jira ticket: RJS-2847

kneth commented 3 weeks ago

@tharwi I am not able to reproduce. How does your code differs from https://github.com/realm/realm-js/pull/6774?

tharwi commented 3 weeks ago

Hi @kneth, thanks for the reply.

Can you change the below line

      const updatedProfile = { ...currentProfile, lastAccessDate: new Date() };

to this

const profile = realmInstance.objects('Profile')[0];
const updatedProfile = {...profile, lastAccessDate: new Date()};
elle-j commented 2 weeks ago

@tharwi, the issue you're seeing is more related to what you're assigning rather than the spread operation itself. When you're using the spread operator it performs a shallow copy, so the trackingDataList on your object will still be the Realm.List from your currentProfile.

This means that when you pass that Realm.List to realm.create(.., .., UpdateMode.Modified), it will basically perform a self-assignment (e.g. object.list = object.list). We do not cache our collections, so even though you're using UpdateMode.Modified, we currently do not know if it's actually the same list being assigned in this case, and we need to clear the underlying collection being assigned to before adding the items of the RHS list (which is why the list becomes empty here).

This is of course something we'd like to fix as soon as possible and the initial work can be tracked here.

As an example, let's say you want to update the valueToUpdate property below:

class ListItem extends Realm.Object {
  value!: number;

  static schema: ObjectSchema = {
    name: "ListItem",
    properties: {
      value: "int",
    },
  };
}

class ObjectWithList extends Realm.Object {
  _id!: BSON.ObjectId;
  list!: Realm.List<ListItem>;
  valueToUpdate!: string;

  static schema: ObjectSchema = {
    name: "ObjectWithList",
    primaryKey: "_id",
    properties: {
      _id: "objectId",
      list: "ListItem[]",
      valueToUpdate: "string",
    },
  };
}

const realm = new Realm({ schema: [ObjectWithList, ListItem] });

const _id = new BSON.ObjectId();
const object = realm.write(() => {
  return realm.create(ObjectWithList, { _id, list: [{ value: 1 }], valueToUpdate: "original" });
});

expect(object.list.length).equals(1);

const objectShallowCopy = { ...object };

// Since it's a shallow copy, the list is still the Realm List.
expect(objectShallowCopy.list).to.be.instanceOf(Realm.List);

realm.write(() => {
  // Unfortunately, passing in the same Realm List again basically
  // performs a self-assignment, clearing the list.
  return realm.create(ObjectWithList, objectShallowCopy, UpdateMode.Modified);
});

// 💥 This will fail.
expect(object.list.length).equals(1);

Workaround:

From the code example you provided, it looks like you only want to update your lastAccessDate field. You can instead pass only the fields to be updated to realm.create() when using UpdateMode.Modified, rather than passing the spread.

realm.write(() => {
-  return realm.create(ObjectWithList, objectShallowCopy, UpdateMode.Modified);
+  return realm.create(ObjectWithList, { _id, valueToUpdate: "updated" }, UpdateMode.Modified);
});

Or skip realm.create() and update the field on the object directly:

 realm.write(() => {
-  return realm.create(ObjectWithList, objectShallowCopy, UpdateMode.Modified);
+  object.valueToUpdate = "updated";
});
tharwi commented 1 week ago

Hi @elle-j , Thanks for the update. Will use one of the workaround for now.

nirinchev commented 10 hours ago

Closing since this is tracked in https://github.com/realm/realm-core/issues/7422.