javascriptbackbone.jsfirebasebackbone.js-collections

The collection nested inside firebase collection's model doesn't have add function


In my application, I am trying to use Firebase to store the real time data based on backbone framework.

The problem goes like this:
I have a sub level model and collection, which are both general backbone model and collection.

var Todo = Backbone.Model.extend({
    defaults: { 
        title: "New Todo",
        completed : true
    }
});

var Todocollection = Backbone.Collection.extend({
    model: Todo,
    initialize: function() {
        console.log("creating a todo collection...");
    },
});

And then there is a high level model, which contains the sublevel collection as an attribute.

var Daymodel = Backbone.Model.extend({
    defaults : {
        day: 1,
        agenda : new Todocollection()
    }
});

and then for the higher level collection, I will firebase collection

var DayCollection = Backbone.Firebase.Collection.extend({
    model: Daymodel
});

So far I can add data to the higher level collection correctly, which has a day attribute and an agenda attribute (which should be a TodoCollection).

The issue is when I try to add data to the sub-level collections, it can't work well.

this.collection.last()
    .get("agenda")
    .add({
        title: this.input.val(), 
        completed: false
    });

The above code will be inside the View part. And this.collection.last() will get the last model. get("agenda") should be the collection object.

But it can't work. The error shows that this.collection.last(...).get(...).add is not a function.

After debugging I found that this.collection.last().get("agenda") returns a general JS object instead of collection object.

I further debugged that if I use backbone collection as the outer collection DayCollection. Everything can go well.

How to solve such problem?


Solution

  • Why the default collection attribute is not a collection anymore?

    When you fetch, or create a new Daymodel which I assume looks like this:

    {
        day: 1,
        agenda : [{
            title: "New Todo",
            completed : false
        }, {
            title: "other Todo",
            completed : false
        }]
    }
    

    The default agenda attribute which was a Todocollection at first gets replaced by a raw array of objects. Backbone doesn't know that agenda is a collection and won't automagically populates it.

    This is what Backbone does with the defaults at model creation (line 401):

    var defaults = _.result(this, 'defaults');
    attrs = _.defaults(_.extend({}, defaults, attrs), defaults);
    this.set(attrs, options);
    

    _.extend({}, defaults, attrs) puts the defaults first, but then, they're overwritten by the passed attrs.

    How to use a collection within a model?

    Below are three solutions to accomplish this. Use only one of them, or create your own based on the followings.

    Easiest and most efficient way is don't.

    Keep the Todocollection out of the Daymodel model and only create the collection when you need it, like in the hypothetical DayView:

    var DayView = Backbone.View.extend({
        initialize: function() {
            // create the collection in the view directly
            this.agenda = new Todocollection(this.model.get('agenda'));
        },
        /* ...snip... */
    });
    

    Then, when there are changes you want to persist in the model, you just put the collection models back into the Daymodel:

    this.model.set('agenda', this.collection.toJSON());
    

    Put the collection into a property of the model

    Instead of an attribute, you could make a function which lazily create the collection and keeps it inside the model as a property, leaving the attributes hash clean.

    var Daymodel = Backbone.Model.extend({
        defaults: { day: 1, },
        getAgenda: function() {
            if (!this.agenda) this.agenda = new Todocollection(this.get('agenda'));
            return this.agenda;
        }
    });
    

    Then, the model controls the collection and it can be shared easily with everything that shares the model already, creating only one collection per instance.

    When saving the model, you still need to pass the raw models back into the attributes hash.

    A collection inside the attributes

    You can accomplish what you're already trying to do with small changes.

    1. Never put objects into the defaults

      ...without using a function returning an object instead.

      var Daymodel = Backbone.Model.extend({
          defaults: function() {
              return {
                  day: 1,
                  agenda: new Todocollection()
              };
          },
      });
      

      Otherwise, the agenda collection would be shared between every instances of Daymodel as the collection is created only once when creating the Daymodel class.

      This also applies to object literals, arrays, functions (why would you put that in the defaults anyway?!).

    2. Ensure it's always a collection.

      var Daymodel = Backbone.Model.extend({
          defaults: { day: 1, },
          initialize: function(attrs, options) {
              var agenda = this.getAgenda();
              if (!(agenda instanceof Todocollection)) {
                  // you probably don't want a 'change' event here, so silent it is.
                  return this.set('agenda', new Todocollection(agenda), { silent: true });
              }
          },
          /**
           * Parse can overwrite attributes, so you must ensure it's a collection
           * here as well.
           */
          parse: function(response) {
              if (_.has(response, 'agenda')) {
                  response.agenda = new Todocollection(response.agenda);
              }
              return response;
          },
          getAgenda: function() {
              return this.get('agenda');
          },
          setAgenda: function(models, options) {
              return this.getAgenda().set(models, options);
          },
      });
      
    3. Ensure it's serializable.

      var Daymodel = Backbone.Model.extend({
          /* ...snip... */
          toJSON: function(options) {
              var attrs = Daymodel.__super__.toJSON.apply(this, arguments),
                  agenda = attrs.agenda;
              if (agenda) {
                  attrs.agenda = agenda.toJSON(options);
              }
              return attrs;
          },
      });
      

      This could easily apply if you put the collection in a model property as explained above.

    4. Avoid accidentally overriding the agenda attribute.

      This goes alongside with point 2 and that's where it's getting hard as it's easy to overlook, or someone else (or another lib) could do that down the line.

      It's possible to override the save and set function to add checks, but it gets overly complex without much gain in the long run.

    What's the cons of collection in models?

    I talked about avoiding it inside a model completely, or lazily creating it. That's because it can get really slow if you instantiate a lot of models and slower if each models is nested multiple times (models which have a collection of models, which have other collections of models, etc).

    When creating it on demand, you only use the machine resources when you need it and only for what's needed. Any model that's not on screen now for example, won't get their collection created.

    Out of the box solutions

    Maybe it's too much work to get this working correctly, so a complete solution might help and there are a couple.