Skip to content

Instantly share code, notes, and snippets.

@heygrady
Last active August 29, 2015 14:27
Show Gist options
  • Save heygrady/05504be81b8bdd43d255 to your computer and use it in GitHub Desktop.
Save heygrady/05504be81b8bdd43d255 to your computer and use it in GitHub Desktop.

Model relationship mixins

Some mixins for managing model relationships.

  • belongsTo('this-model-name', 'to-model-name')
  • hasMany('this-model-name', 'has-model-name')
  • hasManyThrough('this-model-name', 'has-model-name', 'through-model-name')

The mixins are returned by factory functions that take standard arguments to create methods and properties based on the model names provided.

Example: Create Models

Using the mixins you can easily create models with a standard API for managing relationships.

Post with Comments and Tags

Create a model for a post that has comment and tag relationships. The tag relationship is managed through a post-tag model.

import DS from 'ember-data';
import { hasMany, hasManyThrough } from 'relationship/mixins';

// Post
export default DS.Model.extend(
  hasMany('post', 'comment'),
  hasMany('post', 'post-tag'),
  hasManyThrough('post', 'tag', 'post-tag'),
  {
    title: DS.attr('string'),
    body: DS.attr('string'),
  }
);

Comment on Post

A comment belongs to a post.

import DS from 'ember-data';
import { belongsTo } from 'relationship/mixins';

// Comment
export default DS.Model.extend(
  belongsTo('comment', 'post'),
  {
    body: DS.attr('string'),
  }
);

Post Tags

The post-tag model links a post to a tag.

import DS from 'ember-data';
import { belongsTo } from 'relationship/mixins';

// PostTag
export default DS.Model.extend(
  belongsTo('post-tag', 'post'),
  belongsTo('post-tag', 'tag'),
);

Tag

The tag can have many post-tag relationships. It's not usually necessary to make a post directly available from the tag but this could easily be done with hasManyThrough('tag', 'post', 'post-tag').

import DS from 'ember-data';
import { hasMany } from 'relationship/mixins';

// Tag
export default DS.Model.extend(
  hasMany('tag', 'post-tag'),
  {
    name: DS.attr('string'),
  }
);

Example Usage: belongsTo

Using belongsTo. Not super useful.

// fetch a comment and post from the store
const comment = this.store.find('comment', 1);
const post = this.store.find('post'), 1;

// standard
comment.get('post');

// add or remove
comment.addPost(post);
comment.removePost(post);
comment.removePost();

// status
comment.get('isDirty');
comment.get('hasRemovedPost');
comment.get('_removedPost');

// save
comment.save();

// --- better example

// add a post
comment.addPost(post);

// access the post
comment.get('post'); // post

// remove the post
comment.removePost(post);
comment.get('post'); // null

// or remove whichever post
comment.addPost(post);
comment.removePost();
comment.get('post'); // null

// status
comment.get('isDirty'); // true
comment.get('hasRemovedPost'); // true
comment.get('_removedPost'); // post

// save
comment.save();

// ... after save completes
comment.get('isDirty'); // false
comment.get('hasRemovedPost'); // false
comment.get('_removedPost'); // false

Example Usage: hasMany, hasManyThrough

Using hasMany or hasManyThrough relationships is nearly identical. Simplifies working with relationships. Especially useful for sanely managing through relationships.

// fetch a post from the store
const post = this.store.find('post', 1);

// fetching a comment, postTag and tag
const comment = this.store.find('comment', 1);
const postTag = this.store.find('post-tag', 1);
const tag = this.store.find('tag', 1);

// standard lists
post.get('comments');
post.get('postTags');
post.get('tags');

// add, create, remove comment
post.addComment(comment);
post.createComment({body: 'hello.'});
post.removeComment(comment);

post.deleteComment(comment);
post.destroyComment(comment);

// add, create, remove postTag
post.addPostTag(postTag);
post.createPostTag({tag: tag});
post.removePostTag(postTag);

post.deletePostTag(postTag);
post.destroyPostTag(postTag);

// add, create, remove tag
post.addTag(tag);
post.createTag({name: 'hello'});
post.removeTag(tag);

post.deleteTag(tag);
post.destroyTag(tag);

// post status
post.get('hasDirtyAttributes');

// comment status
post.get('hasDirtyComments');
post.get('hasRemovedComments');

// postTag status
post.get('hasDirtyPostTags');
post.get('hasRemovedPostTags');

// tag status
post.get('hasDirtyTags');
post.get('hasRemovedTags');

// special lists
post.get('_removedComments');
post.get('_removedPostTags');
post.get('_removedTags');

// saving
post.saveComments();
post.savePostTags();
post.saveTags();
post.save();

API

Methods: belongsTo

  • addItem(item)
  • removeItem(item)
  • removeItem()
  • save()

Properties: belongsTo

  • item
  • hasRemovedItem
  • _removedItem

Methods: hasMany, hasManyThrough

  • addItem(item)
  • createItem(props)
  • removeItem(item)
  • deleteItem(item)
  • destroyItem(item)
  • saveItems()
  • saveItems(false) note: hasManyThrough only.
  • save()
  • save(false)

Maybe?

  • removeItems(items)
  • deleteItems(items)
  • destroyItems(items)
  • clearItems()

Properties: hasMany, hasManyThrough

  • items
  • hasDirtyItems
  • hasRemovedItems
  • _removedItems

addItem(item)

Adds a relationship to the item from the parent. When defining a relationship, the addItem method will be renamed to match the model relationship being defined. For instance, using the mixin hasMany('post', 'comment') on the post model will make addComment(comment) available.

belongsTo

Presume the comment model is using the belongsTo('comment', 'post') mixin.

// fetch records from the store
const post = this.store.find('post', 1);
const comment = this.store.find('comment', 1);

// has no post
comment.get('post'); // null

// add a post
comment.addPost(post);

// status
comment.get('post'); // post
comment.get('isDirty'); // true

// save
comment.save();

// ... after save completes
comment.get('isDirty'); // false
hasMany

Presume the post model is using the hasMany('post', 'comment') mixin.

// fetch records from the store
const post = this.store.find('post', 1);
const comment = this.store.find('comment', 1);

// has no comments
post.get('comments'); // []

// add a comment to the post
post.addComment(comment);

// status
post.get('hasDirtyComments'); // true
post.get('comments'); // [comment]
post.get('comments.firstObject.isDirty'); // true

// save
post.saveComments();

// ... after save completes
post.get('hasDirtyComments'); // false
post.get('comments.firstObject.isDirty'); // false
hasManyThrough

Presume the post model is using the hasManyThrough('post', 'tag', 'post-tag') mixin along with the hasMany('post', 'post-tag') mixin.

// fetch records from the store
const post = this.store.find('post', 1);
const tag = this.store.find('tag', 1);

// has no postTags or tags
post.get('postTags'); // []
post.get('tags'); // []

// add a tag to the post
post.addTag(tag);

// tag isn't dirty because it hasn't changed
post.get('hasDirtyTags'); // false
post.get('tags'); // [tag]
post.get('tags.firstObject.isDirty'); // false

// a postTag record was auto-created
post.get('hasDirtyPostTags'); // true
post.get('postTags'); // [postTag]
post.get('postTags.firstObject.isDirty'); // true
post.get('postTags.firstObject.isNew'); // true

// save
post.saveTags();

// ... after save completes
post.get('hasDirtyPostTags'); // false
post.get('postTags.firstObject.isDirty'); // false
post.get('postTags.firstObject.isNew'); // false

createItem(props)

Creates a new records and relates it to the parent model. When defining a relationship, the createItem method will be renamed to match the model relationship being defined. For instance, using the mixin hasMany('post', 'comment') on the post model will make createComment(props) available.

hasMany

Presume the post model is using the hasMany('post', 'comment') mixin.

// fetch records from the store
const post = this.store.find('post', 1);

// has no comments
post.get('comments'); // []

// create a comment
post.createComment({body: 'hello'});

// status
post.get('hasDirtyComments'); // true
post.get('comments'); // [comment]
post.get('comments.firstObject.isDirty'); // true
post.get('comments.firstObject.isNew'); // true

// comment is associated with post
const comment = post.get('comments.firstObject');
comment.get('post.id'); // 1

// save
post.saveComments();

// ... after save completes
post.get('hasDirtyComments'); // false
post.get('comments.firstObject.isDirty'); // false
post.get('comments.firstObject.isNew'); // false
hasManyThrough

Presume the post model is using the hasManyThrough('post', 'tag', 'post-tag') mixin along with the hasMany('post', 'post-tag') mixin.

// fetch records from the store
const post = this.store.find('post', 1);

// has no tags
post.get('tags'); // []

// create a tag
post.createTag({name: 'hello'});

// status of tag
post.get('hasDirtyTags'); // true
post.get('tags'); // [tag]
post.get('tags.firstObject.isDirty'); // true
post.get('tags.firstObject.isNew'); // true

// status of postTag
post.get('hasDirtyPostTags'); // true
post.get('postTags'); // [postTag]
post.get('postTags.firstObject.isDirty'); // true
post.get('postTags.firstObject.isNew'); // true

// save
post.saveTags();

// ... after save completes
post.get('hasDirtyTags'); // fasle
post.get('hasDirtyPostTags'); // false

removeItem(item)

Removes an item relationship from the parent.

belongsTo

Presume the comment model is using the belongsTo('comment', 'post') mixin.

// fetch records from the store
const post = this.store.find('post', 1);
const comment = this.store.find('comment', 1);

// has a post
comment.get('post'); // post

// remove a post
comment.removePost(post);

// status
comment.get('post'); // null
comment.get('_removedPost'); // post
comment.get('isDirty'); // true
comment.get('hasRemovedPost'); // true

// save
comment.save();

// ... after save completes
comment.get('_removedPost'); // null
comment.get('isDirty'); // false
comment.get('hasRemovedPost'); // false
hasMany
// fetch records from the store
const post = this.store.find('post', 1);

// has a comment
post.get('comments'); // [comment]

const comment = post.get('comments.firstObject');

// remove the comment
post.removeComment(comment);

// status
post.get('comments'); // []
post.get('hasDirtyComments'); // true
post.get('hasRemovedComments'); // true
post.get('_removedComments'); // [comment]

// comment still exists, without a post
comment.get('post'); // null
comment.get('isDirty'); // true
comment.get('isDeleted'); // false

// save
post.saveComments();

// ... after save completes
post.get('hasDirtyComments'); // false
post.get('hasRemovedComments'); // false
post.get('_removedComments'); // []
comment.get('isDirty'); // false
hasManyThrough
// fetch records from the store
const post = this.store.find('post', 1);

// has a tag
post.get('tags'); // [tag]
const tag = post.get('tags.firstObject');
const postTag = post.get('postTags.firstObject');

// remove the tag
post.removeTag(tag);

// tag status
post.get('tags'); // []
post.get('hasDirtyTags'); // false
post.get('hasRemovedTags'); // true
post.get('_removedTags'); // [tag]

// tag is not touched
tag.get('isDirty'); // false
tag.get('isDeleted'); // false

// postTag status
post.get('postTags'); // []
post.get('hasDirtyPostTags'); // true
post.get('hasRemovedPostTags'); // true
post.get('_removedPostTags'); // [postTag]

// postTag is changed but not deleted
postTag.get('isDirty'); // true
postTag.get('isDeleted'); // false
postTag.get('post'); // null

// save
post.saveTags();

// ... after save completes
post.get('hasRemovedTags'); // false
post.get('hasRemovedPostTags'); // false
post.get('_removedTags'); // []
post.get('_removedPostTags'); // []

postTag.get('isDirty'); // false

removeItem()

Only for belongsTo. Removes an item relationship from the parent. Doesn't require an item to be specified since there is ony one item in a belongsTo relationship.

belongsTo
// fetch records from the store
const comment = this.store.find('comment', 1);

// has a post
comment.get('post'); // post

// remove a post
comment.removePost();

// status
comment.get('post'); // null
comment.get('_removedPost'); // post
comment.get('isDirty'); // true
comment.get('hasRemovedPost'); // true

// save
comment.save();

// ... after save completes
comment.get('_removedPost'); // null
comment.get('isDirty'); // false
comment.get('hasRemovedPost'); // false

deleteItem(item)

Removes an item relationship from the parent and deletes the record.

hasMany
// fetch records from the store
const post = this.store.find('post', 1);

// has a comment
post.get('comments'); // [comment]
const comment = post.get('comments.firstObject');

// delete the comment
post.deleteComment(comment);

// status
post.get('comments'); // []
post.get('hasDirtyComments'); // true
post.get('hasRemovedComments'); // true
post.get('_removedComments'); // [comment]

// comment is deleted
comment.get('post'); // null
comment.get('isDirty'); // true
comment.get('isDeleted'); // true

// save
post.saveComments();

// ... after save completes
post.get('hasDirtyComments'); // false
post.get('hasRemovedComments'); // false
post.get('_removedComments'); // []

comment.get('isDirty'); // false
comment.get('isDeleted'); // true
hasManyThrough
// fetch records from the store
const post = this.store.find('post', 1);

// has a tag
post.get('tags'); // [tag]
const tag = post.get('tags.firstObject');

// delete the tag
post.deleteTag(tag);

// tag status
post.get('tags'); // []
post.get('hasDirtyTags'); // true
post.get('hasRemovedTags'); // true
post.get('_removedTags'); // [tag]

// tag is deleted
tag.get('isDirty'); // true
tag.get('isDeleted'); // true

// postTag status
post.get('postTags'); // []
post.get('hasDirtyPostTags'); // true
post.get('hasRemovedPostTags'); // true

// postTag is deleted too
const postTag = post.get('_removedPostTags.firstObject');
postTag.get('isDirty'); // true
postTag.get('isDeleted'); // true

// save
post.saveTags();

// ... after save completes
post.get('hasDirtyTags'); // false
post.get('hasRemovedTags'); // false
post.get('hasDirtyPostTags'); // false
post.get('hasRemovedPostTags'); // false

tag.get('isDirty'); // false
tag.get('isDeleted'); // true
postTag.get('isDirty'); // false
postTag.get('isDeleted'); // true

destroyItem(item)

Removes an item relationship from the parent, deletes the record and saves the deletion.

hasMany
// fetch records from the store
const post = this.store.find('post', 1);

// has a comment
post.get('comments'); // [comment]

const comment = post.get('comments.firstObject');

// destroy the comment
post.destroyComment(comment);

// ... after save completes
post.get('comments'); // []
post.get('hasDirtyComments'); // false
post.get('hasRemovedComments'); // false
post.get('_removedComments'); // []

// comment is destroyed
comment.get('post'); // null
comment.get('isDirty'); // false
comment.get('isDeleted'); // true
hasManyThrough
// fetch records from the store
const post = this.store.find('post', 1);

// has a tag
post.get('tags'); // [tag]
const postTag = post.get('postTags.firstObject');
const tag = post.get('tags.firstObject');

// destroy the tag
post.destroyTag(tag);

// ... after save completes
// tag status
post.get('tags'); // []
post.get('hasDirtyTags'); // false
post.get('hasRemovedTags'); // false
post.get('_removedTags'); // []

// tag is destroyed
tag.get('isDirty'); // false
tag.get('isDeleted'); // true

// postTag status
post.get('postTags'); // []
post.get('hasDirtyPostTags'); // false
post.get('hasRemovedPostTags'); // false

// postTag is destroyed too
postTag.get('isDirty'); // false
postTag.get('isDeleted'); // true

saveItems()

Saves any items related to the parent that are dirty, including those that have been removed or deleted.

hasMany
// fetch records from the store
const post = this.store.find('post', 1);

// has a comment
post.get('comments'); // [comment]

// make some changes
post.addComment(comment);
post.createComment({body: 'hello'});
post.deleteComment(post.get('comments.firstObject'));

// status
post.get('comments'); // [comment, comment]
post.get('hasDirtyComments'); // true
post.get('hasRemovedComments'); // true

// save
post.saveComments();

// ... after save completes
post.get('hasDirtyComments'); // false
post.get('hasRemovedComments'); // false
hasManyThrough
// fetch records from the store
const post = this.store.find('post', 1);

// has a tag
post.get('tags'); // [tag]

// make some changes
post.addTag(tag);
post.createTag({name: 'hello'});
post.deleteTag(post.get('tags.firstObject'));

// tag status
post.get('tags'); // [tags, tags]
post.get('_removedTags'); // [tag]
post.get('hasDirtyTags'); // true
post.get('hasRemovedTags'); // true

// postTag status
post.get('postTags'); // [postTag, postTag]
post.get('hasDirtyPostTags'); // true
post.get('hasRemovedPostTags'); // true

// save
post.saveTags();

// ... after save completes
post.get('hasDirtyTags'); // false
post.get('hasRemovedTags'); // false
post.get('hasDirtyPostTags'); // false
post.get('hasRemovedPostTags'); // false

saveItems(false)

Only for hasManyThrough. Same as saveItems() except it does not save the "through" records.

hasManyThrough
// fetch records from the store
const post = this.store.find('post', 1);

// has a tag
post.get('tags'); // [tag]

// make some changes
post.addTag(tag);
post.createTag({name: 'hello'});
post.deleteTag(post.get('tags.firstObject'));

// tag status
post.get('tags'); // [tags, tags]
post.get('_removedTags'); // [tag]
post.get('hasDirtyTags'); // true
post.get('hasRemovedTags'); // true

// postTag status
post.get('postTags'); // [postTag, postTag]
post.get('hasDirtyPostTags'); // true
post.get('hasRemovedPostTags'); // true

// save
post.saveTags(false);

// ... after save completes
// tags are saved
post.get('hasDirtyTags'); // false
post.get('hasRemovedTags'); // false

// post tags are not saved
post.get('hasDirtyPostTags'); // true
post.get('hasRemovedPostTags'); // true

save()

The relationship mixins override the save() on the model itself in order to automatically save changes to any of its relationships. The order the mixins are added to the model will have an effect on the order the saveItems() calls are made.

save override example

Presume we define our model like this:

import DS from 'ember-data';
import { hasMany, hasManyThrough } from 'relationship/mixins';

// Post
export default DS.Model.extend(
  hasMany('post', 'comment'),
  hasMany('post', 'post-tag'),
  hasManyThrough('post', 'tag', 'post-tag'),
  {
    title: DS.attr('string'),
    body: DS.attr('string'),
  }
);

Our save method will work a little bit like this:

// psuedo-code
// in reality this is some form of callback hell (super hell?)
// the code below wouldn't actually execute as written
save() {
  // save tags was defined last
  // so it is the first override called
  // save() saves the tags first
  this.saveTags();

  // then saveTags() calls savePostTags()
  this.savePostTags();

  // then saveTags() calls _super()
  this._super.apply(this, arguments);

  // _super() is the postTags override
  // postTags needs the post to be saved first
  this._super.apply(this, arguments);

  // _super() is the comments override
  // comments needs the post to be saved first too
  this._super.apply(this, arguments);

  // _super() calls the real save()
  this.save();

  // then comments can finish its override
  this.saveComments();
  
  // then postTags can finish its override
  this.savePostTags(); // does nothing
}

Rewritten as a nested function.

// psuedo-code
// it's more like this but not entirely
function save () {
  const self = this;
  
  // from hasManyThrough('post', 'tag', 'post-tag')
  function save() {
    // from hasMany('post', 'post-tag')
    function save() {
      // hasMany('post', 'comment')
      function save() {
        // real save
        function save() {
          // ... whatever the model does
        }
        
        // super first
        save();

        // then save comments
        self.saveComments();
      }
      
      // super first
      save();

      // then save post tags
      self.savePostTags();
    }
    
    // save tags
    self.saveTags(); // internally calls self.savePostTags();

    // then super
    save();
  }
  save();
}
belongsTo

Doesn't actually override the save() method because it's not necessary.

// fetch records from the store
const comment = this.store.find('comment', 1);

// make a change
comment.removePost();

// save it
comment.save();
hasMany
// fetch records from the store
const post = this.store.find('post', 1);

// make a change
post.createComment({body: 'hello'});

// save it
post.save();
// calls post.saveComments()
// then post.save();
// returns RSVP hash?
hasManyThrough
// fetch records from the store
const post = this.store.find('post', 1);

// make a change
post.createTag({name: 'hello'});

// save it
post.save();
// calls post.saveTags()
// then post.savePostTags();
// then post.save();
// returns RSVP hash?

save(false)

Same as save() except the relationship overrides are not called.

belongsTo

Doesn't actually override the save() method because it's not necessary.

// fetch records from the store
const comment = this.store.find('comment', 1);

// make a change
comment.removePost();

// save it
comment.save(false); // undefined behavior
hasMany
// fetch records from the store
const post = this.store.find('post', 1);

// make a change
post.createComment({body: 'hello'});

// save it
post.save(false);

// ... after save completes
post.get('hasDirtyComments'); // true
hasManyThrough
// fetch records from the store
const post = this.store.find('post', 1);

// make a change
post.createTag({name: 'hello'});

// save it
post.save(false);

// ... after save completes
post.get('hasDirtyTags'); // true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment