How to rollback relationship changes in EmberData
Asked Answered
T

4

8

I have two models with parent-child relationship: training and exercise:

App.Training = DS.Model.extend({
  exercises: DS.hasMany('App.Exercise')
})

App.Exercise = DS.Model.extend({
  training: DS.belongsTo('App.Training')
})

I want to have a page where a training with all its related exercises is displayed. If the user presses the Edit button, the page becomes editable with the possibility of adding new exercises. I also want to have a Cancel button which discards all the changes made.

Here is my controller:

App.TrainingsShowController = Em.ObjectController.extend({
  editing: false,

  edit: function() {
    this.set('editing', true);
    transaction = this.get('store').transaction();
    transaction.add(this.get('model'));
    this.get('model.exercises').forEach(function(x){
      transaction.add(x);
    });
  },

  cancel: function() {
    this.set('editing', false);
    this.get('model.transaction').rollback();
  },

  save: function() {
    this.set('editing', false);
    this.get('model.transaction').commit();
  },

  addExercise: function() {
    this.get('model.exercises').createRecord({});
  }
})

There are four event handlers in the controller:

  1. edit: The user pressed the Edit button: a transaction is created, the page is put into "Editing" mode.
  2. cancel: The user pressed the Cancel button: transaction is rolled back and back to "Normal" mode.
  3. save: The user pressed the Save button: transaction is commited and back to "Normal" mode.
  4. addExercise: The user pressed the Add exercise button: a new exercise is created (in the same transaction) and added to the trainings.

The rollback functionality works fine except for newly created records: if I push the Edit button, add a new exercise and push the Cancel button, the newly created exercise stays on the page.

What is the best way to get rid of the discarded child record?

UPDATE:

I've created a jsFiddle to reproduce problem, but it worked. Unlike my application here I used DS.FixtureAdapter: http://jsfiddle.net/tothda/LaXLG/13/

Then I've created an other one using DS.RESTAdapter and the problem showed up: http://jsfiddle.net/tothda/qwZc4/5/

In the fiddle try: Edit, Add new and then Rollback.

I figured it out, that in case of the RESTAdapter when I add a new child record to a hasMany relationship, the parent record won't become dirty. Which seems fine, but when I rollback the transaction, the newly created child record stays in the parent's ManyArray.

I still don't know, what's the best way to handle the situation.

Theressathereto answered 4/2, 2013 at 10:45 Comment(0)
S
12

A proper dirty check and rollback for hasMany and belongsTo relationships are sorely lacking in Ember Data. The way it currently behaves is often reported as a bug. This is a big pain point for a lot of developers and there is an ongoing discussion on how to resolve this here:

https://github.com/emberjs/rfcs/pull/21

Until there's a proper solution in place, you can workaround this problem by using the following approach.

First, you'll want to reopen DS.Model and extend it. If you're using globals, you can can just put this (e.g. DS.Model.reopen({})) anywhere, but if you're using Ember CLI, it's best to create an initializer (e.g. ember g initializer model):

import DS from 'ember-data';

export function initialize(/* container, application */) {

    DS.Model.reopen({

        saveOriginalRelations: function() {

            this.originalRelations = {};
            this.constructor.eachRelationship(function(key, relationship) {

                if (relationship.kind === 'belongsTo')
                    this.originalRelations[key] = this.get(key);

                if (relationship.kind === 'hasMany')
                    this.originalRelations[key] = this.get(key).toArray();

            }, this);
        },

        onLoad: function() {

            this.saveOriginalRelations();

        }.on('didLoad', 'didCreate', 'didUpdate'),

        onReloading: function() {

            if (!this.get('isReloading'))
                this.saveOriginalRelations();

        }.observes('isReloading'),    

        rollback: function() {

            this._super();

            if (!this.originalRelations)
                return;

            Ember.keys(this.originalRelations).forEach(function(key) {

                // careful, as Ember.typeOf for ArrayProxy is 'instance'
                if (Ember.isArray(this.get(key))) {
                    this.get(key).setObjects(this.originalRelations[key]);
                    this.get(key).filterBy('isDirty').invoke('rollback');
                    return;
                }

                if (Ember.typeOf(this.get(key)) === 'instance') {
                    this.set(key, this.originalRelations[key]);
                    return;
                }

            }, this);
        },

        isDeepDirty: function() {
            if (this._super('isDirty'))
                return true;

            if (!this.originalRelations)
                return false;

            return Ember.keys(this.originalRelations).any(function(key) {

                if (Ember.isArray(this.get(key))) {
                    if (this.get(key).anyBy('isDirty'))
                        return true;

                    if (this.get(key).get('length') !== this.originalRelations[key].length)
                        return true;

                    var dirty = false;
                    this.get(key).forEach(function(item, index) {
                        if (item.get('id') !== this.originalRelations[key][index].get('id'))
                            dirty = true;
                    }, this);

                    return dirty;
                }

                return this.get(key).get('isDirty') || this.get(key).get('id') !== this.originalRelations[key].get('id');

            }, this);
        }
    });
};

export default {
    name: 'model',
    initialize: initialize
};

The code above essentially stores the original relationships on load or update so that it can later be used for rollback and dirty checking.

model.rollback() should now roll back everything, including hasMany and belongsTo relationships. We still haven't fully addressed the 'isDirty' check though. To do that, we need to override isDirty in the concrete implementation of a model. The reason why we need to do it here and we can't do it generically in DS.Model is because DS.Model doesn't know what property changes to watch for. Here's an example using Ember CLI. The same approach would be used with globals, except that you'd assign this class to something like App.Book:

import DS from 'ember-data';

var Book = DS.Model.extend({

    publisher: DS.belongsTo('publisher'),

    authors: DS.hasMany('author'),

    isDirty: function() {
        return this.isDeepDirty();
    }.property('currentState', 'publisher', 'authors.[]', '[email protected]').readOnly()

});

export default Book;

For the dependent arguments of isDirty, make sure to include all belongsTo relationships and also include 'array.[]' and '[email protected]' for every hasMany relationship. Now isDirty should work as expected.

Shophar answered 28/11, 2014 at 7:54 Comment(4)
This is a nice idea, but it seems like now my application is loading all async relationsships right away (which takes a while), instead of waiting until the are actually used? Correct me if i am wrong.Modla
@justastefan, good observation. saveOriginalRelations is probably loading all async relationships. If you have async relationships, then it'll be trickier to implement this technique. You'll need to store original relations only after they've been loaded, and ignore rolling back any relations that haven't been loaded.Shophar
While upgrading to ember-data 1.13.7 I tried to update this workaround too but no success. Can you help please?Erik
Here is an update of the above for Ember 2.1. The trick @Erik is to do a findRecord to get both the model and its related records. My use case also had potentially-deleted related records, and the fix for undeleting them was to rollback before setting the original value back on the model.Torres
A
1

This isn't pretty but you can force it to rollback by manually dirtying the parent record:

parent.send('becomeDirty');
parent.rollback();
parent.get('children.length'); // => 0
Ackack answered 11/4, 2013 at 3:44 Comment(0)
P
0

@tothda and other readers to follow. As of Ember Data : 1.0.0-beta.10+canary.7db210f29a the parent is still not designed to make parentTraining.isDirty() a value of true when a child is rolled back. Ember Data does consider a parent record to be dirty when an attribute is changed, but not when a DS.hasMany array has changes (this allows save() to work, so you can updated any changes to the parent's attributes on the server).

The way around this for the case mentioned, where you want to do a rollback() on a newly created child, is to replace the .rollback() with a .deleteRecord() on the child record you want to discard. Ember Data then automatically knows to remove it from the DS.hasMany array then, and you can pat yourself on the back for a rollback well done!

Plato answered 28/8, 2014 at 13:58 Comment(0)
H
0

Late to the party, but here we go:

I created an addon that resolves this issue. Just call rollbackRelationships() and it will rollback all your relationships (belongsTo & hasMany). Look at the README for more options.

https://www.npmjs.com/package/ember-rollback-relationships

Handicapped answered 15/12, 2016 at 16:16 Comment(2)
where's the repo?Polyclitus
@Polyclitus I have no idea why it is not showing up, here it is: github.com/dylanmensaert/ember-rollback-relationshipsHandicapped

© 2022 - 2024 — McMap. All rights reserved.