Skip to content

FreakenK/backbone-associations

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Backbone-associations

Backbone-associations provides a way of specifying 1:1 and 1:N relationships between Backbone models. Additionally, parent model instances (and objects extended from Backbone.Events) can listen in to CRUD events initiated on any children - in the object graph - by providing an appropriately qualified event path name. It aims to provide a clean implementation which is easy to understand and extend. It is performant for CRUD operations - even on deeply nested object graphs - and uses a low memory footprint. Web applications leveraging the client-side-MVC architectural style will benefit by using backbone-associations to define and manipulate client object graphs.

It comes with

  • The annotated source code.
  • An online test suite which includes backbone test cases run with AssociatedModels.
  • Performance tests.

It was originally born out of a need to provide a simpler and speedier implementation of Backbone-relational

Contents

Backbone-associations depends on backbone (and thus on underscore). Include Backbone-associations right after Backbone and Underscore:

<script type="text/javascript" src="./js/underscore.js"></script>
<script type="text/javascript" src="./js/backbone.js"></script>
<script type="text/javascript" src="./js/backbone-associations.js"></script>

Backbone-associations works with Backbone v0.9.10. Underscore v1.4.3 upwards is supported.

Each Backbone.AssociatedModel can contain an array of relations. Each relation defines a relatedModel, key, type and (optionally) collectionType. This can be easily understood by some examples.

Specifying One-to-One Relationship

var Employee = Backbone.AssociatedModel.extend({
    relations: [
        {
            type: Backbone.One, //nature of the relationship
            key: 'manager', // attribute of Employee
            relatedModel: 'Employee' //AssociatedModel for attribute key
        }
    ],
  defaults: {
    age : 0,
    fname : "",
    lname : "",
    manager : null
  }
});

Specifying One-to-Many Relationship

var Location = Backbone.AssociatedModel.extend({
  defaults: {
    add1 : "",
    add2 : null,
    zip : "",
    state : ""
  }
});

var Project = Backbone.AssociatedModel.extend({
  relations: [
      {
        type: Backbone.Many,//nature of the relation
        key: 'locations', //attribute of Project
        relatedModel:Location //AssociatedModel for attribute key
      }
  ],
  defaults: {
    name : "",
    number : 0,
    locations : []
  }
});

Valid values for

relatedModel

A string (which can be resolved to an object type on the global scope), or a reference to a Backbone.AssociatedModel type.

key

A string which references an attribute name on relatedModel.

type : Backbone.One or Backbone.Many

Used for specifying one-to-one or one-to-many relationships.

collectionType (optional) :

A string (which can be resolved to an object type on the global scope), or a reference to a Backbone.Collection type. Determine the type of collections used for a Many relation.

This tutorial demonstrates how to convert the following relationship graph into an AssociatedModels representation cd_example This image was generated via code.

   var Location = Backbone.AssociatedModel.extend({
        defaults:{
            add1:"",
            add2:null,
            zip:"",
            state:""
        }
    });

    var Project = Backbone.AssociatedModel.extend({
        relations:[
            {
                type:Backbone.Many,
                key:'locations',
                relatedModel:Location
            }
        ],
        defaults:{
            name:"",
            number:0,
            locations:[]
        }
    });


    var Department = Backbone.AssociatedModel.extend({
        relations:[
            {
                type:Backbone.Many,
                key:'controls',
                relatedModel:Project
            },
            {
                type:Backbone.Many,
                key:'locations',
                relatedModel:Location
            }
        ],
        defaults:{
            name:'',
            locations:[],
            number:-1,
            controls:[]
        }
    });

    var Dependent = Backbone.AssociatedModel.extend({
        validate:function (attr) {
            return (attr.sex && attr.sex != "M" && attr.sex != "F") ? "invalid sex value" : undefined;
        },
        defaults:{
            fname:'',
            lname:'',
            sex:'F', //{F,M}
            age:0,
            relationship:'S' //Values {C=Child, P=Parents}
        }
    });

    var Employee = Backbone.AssociatedModel.extend({
        relations:[
            {
                type:Backbone.One,
                key:'works_for',
                relatedModel:Department
            },
            {
                type:Backbone.Many,
                key:'dependents',
                relatedModel:Dependent
            },
            {
                type:Backbone.One,
                key:'manager',
                relatedModel:'Employee'
            }
        ],
        validate:function (attr) {
            return (attr.sex && attr.sex != "M" && attr.sex != "F") ? "invalid sex value" : undefined;
        },
        defaults:{
            sex:'M', //{F,M}
            age:0,
            fname:"",
            lname:"",
            works_for:{},
            dependents:[],
            manager:null
        }
    });

CRUD operations on AssociatedModels trigger the appropriate Backbone system events. However, because we are working with an object graph, the event name now contains the fully qualified path from the source of the event to the receiver of the event. The remaining event arguments are identical to the Backbone event arguments and vary based on event type.

An update like this

emp.get('works_for').get("locations").at(0).set('zip', 94403);

can be listened to at various levels by spelling out the appropriate path

emp.on('change:works_for.locations[0].zip', callback_function);

emp.get('works_for').on('change:locations[0].zip', callback_function);

emp.get('works_for').get('locations').at(0).on('change:zip', callback_function);

With backbone v0.9.9 onwards, another object can also listen in to events like this

var listener = {};
_.extend(listener, Backbone.Events);

listener.listenTo(emp, 'change:works_for.locations[0].zip', callback_function);

listener.listenTo(emp.get('works_for'), 'change:locations[0].zip', callback_function);

listener.listenTo(emp.get('works_for').get('locations').at(0), 'change:zip', callback_function);

A detailed example is provided below to illustrate the behavior for other event types as well as the appropriate usage of the Backbone change-related methods used in callbacks.

This tutorial demonstrates the usage of eventing and change-related methods with AssociatedModels

Setup of relationships between AssociatedModel instances

    emp = new Employee({
        fname:"John",
        lname:"Smith",
        age:21,
        sex:"M"
    });

    child1 = new Dependent({
        fname:"Jane",
        lname:"Smith",
        sex:"F",
        relationship:"C"

    });

    child2 = new Dependent({
        fname:"Barbara",
        lname:"Ruth",
        sex:"F",
        relationship:"C"

    });

    parent1 = new Dependent({
        fname:"Edgar",
        lname:"Smith",
        sex:"M",
        relationship:"P"

    });

    loc1 = new Location({
        add1:"P.O Box 3899",
        zip:"94404",
        state:"CA"

    });

    loc2 = new Location({
        add1:"P.O Box 4899",
        zip:"95502",
        state:"CA"
    });

    project1 = new Project({
        name:"Project X",
        number:"2"
    });

    project2 = new Project({
        name:"Project Y",
        number:"2"
    });

    project2.get("locations").add(loc2);
    project1.get("locations").add(loc1);

    dept1 = new Department({
        name:"R&D",
        number:"23"
    });

    dept1.set({locations:[loc1, loc2]});
    dept1.set({controls:[project1, project2]});

    emp.set({"dependents":[child1, parent1]});

Assign Associated Model instances to other properties

    emp.on('change', function () {
        console.log("Fired emp > change...");
        //emp.hasChanged() === true;
        //emp.hasChanged("works_for") === true;
    });
    emp.on('change:works_for', function () {
        console.log("Fired emp > change:works_for...");
        var changed = emp.changedAttributes();
        //changed['works_for'].toJSON() equals emp.get("works_for").toJSON()
        //emp.previousAttributes()['works_for'].get('name') === "");
        //emp.previousAttributes()['works_for'].get('number') === -1;
        //emp.previousAttributes()['works_for'].get('locations').length === 0;
        //emp.previousAttributes()['works_for'].get('controls').length === 0;
    });

    emp.set({works_for:dept1});
    //Console log
    //Fired emp > change:works_for...
    //Fired emp > change...

Update attributes of AssociatedModel instances

    //Remove event handlers. Can also use backbone 0.9.9+ once API (on the previous emp event handlers)
    emp.off()

    emp.get('works_for').on('change', function () {
        console.log("Fired emp.works_for > change...");
        //emp.get("works_for").hasChanged() === true;
        //emp.get("works_for").previousAttributes()["name"] === "R&D";
    });
    emp.get('works_for').on('change:name', function () {
        console.log("Fired emp.works_for > change:name...");

    });

    emp.on('change:works_for.name', function () {
        console.log("Fired emp > change:works_for.name...");
        //emp.get("works_for").hasChanged() === true;
        //emp.hasChanged() === true;
        //emp.hasChanged("works_for") === true;
        //emp.changedAttributes()['works_for'].toJSON() equals emp.get("works_for").toJSON();
        //emp.get("works_for").previousAttributes()["name"] === "R&D";
        //emp.get("works_for").previous("name") === "R&D";
    });

    emp.on('change:works_for', function () {
        console.log("Fired emp > change:works_for...");
        //emp.hasChanged());
        //emp.hasChanged("works_for"));
        //emp.changedAttributes()['works_for'].toJSON() equals emp.get("works_for").toJSON();
        //emp.previousAttributes().works_for.name === "R&D";
    });

    emp.get('works_for').set({name:"Marketing"});

    //Console log
    //Fired emp.works_for > change:name
    //Fired emp > change:works_for.name...
    //Fired emp.works_for > change...
    //Fired emp > change:works_for...

Update an item in a Collection of AssociatedModels

    emp.get('works_for').get('locations').at(0).on('change:zip', function () {
        console.log("Fired emp.works_for.locations[0] > change:zip...");
    });

    emp.get('works_for').get('locations').at(0).on('change', function () {
        console.log("Fired emp.works_for.locations[0] > change...");
    });

    emp.get('works_for').on('change:locations[0].zip', function () {
        console.log("Fired emp.works_for > change:locations[0].zip...");
    });

    emp.get('works_for').on('change:locations[0]', function () {
        console.log("Fired emp.works_for > change:locations[0]...");
    });

    emp.on('change:works_for.locations[0].zip', function () {
        console.log("Fired emp > change:works_for.locations[0].zip...");
    });

    emp.on('change:works_for.locations[0]', function () {
        console.log("Fired emp > change:works_for.locations[0]...");
    });

    emp.on('change:works_for.controls[0].locations[0].zip', function () {
        console.log("Fired emp > change:works_for.controls[0].locations[0].zip...");
    });

    emp.on('change:works_for.controls[0].locations[0]', function () {
        console.log("Fired emp > change:works_for.controls[0].locations[0]...");
    });

    emp.get('works_for').on('change:controls[0].locations[0].zip', function () {
        console.log("Fired emp.works_for > change:controls[0].locations[0].zip...");
    });

    emp.get('works_for').on('change:controls[0].locations[0]', function () {
        console.log("Fired emp.works_for > change:controls[0].locations[0]...");
    });

    emp.get('works_for').get("locations").at(0).set('zip', 94403);

    //Console log
    //Fired emp.works_for > change:controls[0].locations[0]...
    //Fired emp.works_for > change:controls[0].locations[0].zip...
    //Fired emp.works_for > change:locations[0]...
    //Fired emp.works_for > change:locations[0].zip...

    //Fired emp > change:works_for.controls[0].locations[0]...
    //Fired emp > change:works_for.controls[0].locations[0].zip...
    //Fired emp > change:works_for.locations[0]...
    //Fired emp > change:works_for.locations[0].zip...

    //Fired emp.works_for.locations[0] > change...
    //Fired emp.works_for.locations[0].zip > change...

Add, remove and reset operations

    emp.on('add:dependents', function () {
        console.log("Fired emp > add:dependents...");
    });
    emp.on('remove:dependents', function () {
        console.log("Fired emp > remove:dependents...");
    });
    emp.on('reset:dependents', function () {
        console.log("Fired emp > reset:dependents...");
    });

    emp.get('dependents').on('add', function () {
        console.log("Fired emp.dependents add...");
    });
    emp.get('dependents').on('remove', function () {
        console.log("Fired emp.dependents remove...");
    });
    emp.get('dependents').on('reset', function () {
        console.log("Fired emp.dependents reset...");
    });

    emp.get("dependents").add(child2);
    emp.get("dependents").remove([child1]);
    emp.get("dependents").reset();

    //Console log
    //Fired emp.dependents add...
    //Fired Fired emp.dependents remove...
    //Fired emp.dependents reset...

    //Fired emp > add:dependents...
    //Fired emp > remove:dependents...
    //Fired emp > reset:dependents...

The preceding examples corresponds to this test case. Other examples can be found in the test suite.

For convenience, it is also possible to retrieve or set data by specifying a path to the destination (of the retrieve or set operation).

    emp.get('works_for.controls[0].locations[0].zip') //94404
    //Equivalent to emp.get('works_for').get('controls').at(0).get('locations').at(0).get('zip');
    emp.set('works_for.locations[0].zip', 94403);
    //Equivalent to emp.get('works_for').get('locations').at(0).set('zip',94403);
Query the appropriate object to determine change

When assigning a previously created object graph to a property in an associated model, care must be taken to query the appropriate object for the changed properties.

dept1 = new Department({
    name:"R&D",
    number:"23"
});

//dept1.hasChanged() === false;

emp.set('works_for', dept1);

Then inside a previously defined change event handler

emp.on('change:works_for', function () {
    //emp.get('works_for').hasChanged() === false; as we query a previously created `dept1` instance
    //emp.hasChanged('works_for') === true; as we query emp whose 'works_for' attribute has changed
});
Use unqualified change event name with care

This extension makes use of fully-qualified-event-path names to identify the location of the change in the object graph. (And the event arguments would have the changed object or object property).

The unqualified change event would work if an entire object graph is being replaced with another. For example

    emp.on('change', function () {
        console.log("emp has changed");//This WILL fire
    });
    emp.on('change:works_for', function () {
        console.log('emp attribute works_for has changed');//This WILL fire
    });
    emp.set('works_for', {name:'Marketing', number:'24'});

However, if attributes of a nested object are changed, the unqualified change event will not fire for objects (and their parents) who have that nested object as their child.

    emp.on('change', function () {
            console.log("emp has changed"); //This will NOT fire
    });
    emp.on('change:works_for', function () {
            console.log('emp attribute works_for has changed');//This WILL fire
    });
    emp.get('works_for').set('name','Marketing');

Refer to issue #28 for a more detailed reasoning.

Performance

Each operation comprises of n (10, 15, 20, 25, 30) inserts. The chart above compares the performance (time and operations/sec) of the two implementations. (backbone-associations v0.4.1 v/s backbone-relational v0.7.1)

Run tests on your machine configuration instantly here

Write your own test case here

Version 0.4.2 - Diff

  • Support for backbone 1.0.0.

Version 0.4.1 - Diff

  • Support for backbone 0.9.10.
  • Faster (Non-recursive) implementation of AssociatedModel change-related methods.

Version 0.4.0 - Diff

  • Ability to perform set and retrieve operations with fully qualified paths.

Version 0.3.1 - Diff

  • Bug fix for event paths involving collections at multiple levels in the object graph.
  • Updated README with class diagram and example for paths involving collections.

Version 0.3.0 - Diff

  • Added support for fully qualified event "path" names.
  • Event arguments and event paths are semantically consistent.
  • Now supports both backbone 0.9.9 and 0.9.2.
  • New tutorials on usage. (part of README.md)

Version 0.2.0 - Diff

Added support for cyclic object graphs.

Version 0.1.0

Initial Backbone-associations release.

About

Create lightweight object graphs with Backbone models

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • JavaScript 100.0%