7. Relations

Describing how your Resources are related

🚧

This tutorial needs review

Getting started

This page assumes you've read part 6 of the tutorial, Schemas & Validation.

Relations describe the associations between your different Resources. For example, many Comments could be associated with a single blog Post. Many blog Posts could be associated with a single User. A single User could be associated with many Comments. A single User could be associated with many blog Posts. The type of relationship depends on the direction from which you describe it.

You do not have to define relations in order to use JSData, but doing so allows you to use some convenience features of JSData.

πŸ‘

Tip

There are essentially two types of relationships: one-to-one and one-to-many.

many-to-many relationships are generally achieved through some combination of one-to-many and one-to-one relationships.

Defining relations

Relations are defined as part of a Mapper's configuration. When defining a Mapper, add a relations property to the options object:

import { Container } from 'js-data';

const store = new Container();

store.defineMapper('user', {
  relations: {
    hasMany: {
      post: {
        // In a hasMany relationship configured with
        // a foreignKey, the foreignKey specifies the
        // property of the child record that points
        // to the parent record,
        // i.e. console.log(post.user_id); // 12345
        foreignKey: 'user_id',
        // In memory, a user's posts will be attached to
        // user objects via the user's "posts" property,
        // i.e. console.log(user.posts); // [{...}, {...}, ...]
        // and console.log(user.posts[0].user_id); // 12345
        localField: 'posts'
      }
    }
  }
});

store.defineMapper('post', {
  relations: {
    belongsTo: {
      user: {
        // In a belongsTo relationship, the foreignKey
        // specifies the property of the child record
        // that points to the parent record,
        // i.e. console.log(post.user_id); // 12345
        foreignKey: 'user_id',
        // In memory, a post's user will be attached to
        // post objects via the post's "user" property,
        // i.e. console.log(post.user); // {...}
        // and console.log(post.user[post.user_id]); // 12345
        localField: 'user'
      }
    }
  }
});

// some data representations
const user = {
  id: 1,
  posts: [{ id: 34, user_id: 1 }, { id: 35, user_id: 1 }]
};

const post = {
  id: 34,
  user_id: 1,
  user: {
    id: 1
  }
};

πŸ‘

Tip

It's important to define both sides of a relationship. Notice that in the sample above there are two relation definitions: user hasMany post and post belongsTo user. By defining both sides of the relationship, JSData can load a user's posts or load a post's user, as well link them together in memory for convenient relationship traversal.

Canonical relationship examples

These three examples capture the majority of possible relation configurations:

User hasMany (foreignKey) Post

js store.defineMapper('user', { relations: { hasMany: { post: { foreignKey: 'user_id', localField: 'posts' } } } }); const user = { id: 1212 };

Example user record with its posts embedded:
js const user = { id: 1212, posts: [ { id: 3343, user_id: 1212 }, { id: 3344, user_id: 1212 } ] };
Post belongsTo (foreignKey) User

js store.defineMapper('post', { relations: { belongsTo: { user: { foreignKey: 'user_id', localField: 'user' } } } }); const posts = [ { id: 3343, user_id: 1212 }, { id: 3344, user_id: 1212 } ];

Example post record with its user embedded:
js const post = { id: 3343, user_id: 1212, user: { id: 1212 } };
User hasMany (localKeys) Post

js store.defineMapper('user', { relations: { hasMany: { post: { localKeys: 'post_ids', localField: 'posts' } } } }); const user = { id: 1212, post_ids: [3343, 3344] };

Example user record with its posts embedded:
js const user = { id: 1212, post_ids: [3343, 3344], posts: [ { id: 3343 }, { id: 3344 } ] };
Post hasMany (foreignKeys) User

js store.defineMapper('post', { relations: { belongsTo: { user: { foreignKeys: 'post_ids', localField: 'users' } } } }); const post = [ { id: 3343 }, { id: 3344 } ];

Example post record with its user embedded:
js const post = { id: 3343, user: { id: 1212, post_ids: [3343, 3344] } };
User hasOne (foreignKey) Profile

js store.defineMapper('user', { relations: { hasOne: { profile: { foreignKey: 'user_id', localField: 'profile' } } } }); const user = { id: 1212 };

Example user record with its profile embedded:
js const user = { id: 1212, profile: { id: 5545, user_id: 1212 } };
Profile belongsTo (foreignKey) User

js store.defineMapper('profile', { relations: { belongsTo: { user: { foreignKey: 'user_id', localField: 'user' } } } }); const profile = { id: 5545, user_id: 1212 };

Example profile record with its user embedded:
js const profile = { id: 5545, user_id: 1212, user: { id: 1212 } };

belongsTo relationship

The belongsTo relationship defines the association between a child record and its parent record. The relationship information is typically stored in a "foreign key" on the child record, meaning, the child record has a field whose value is the primary key of the parent record. A belongsTo relationship can be paired with a hasMany relationship that uses the foreignKey option or a hasOne relationship.

Example

store.defineMapper('post', {
  relations: {
    belongsTo: {
      user: {
        foreignKey: 'user_id',
        localField: 'user'
      }
    }
  }
});
store.defineMapper('user', {
  relations: {
    hasMany: {
      post: {
        foreignKey: 'user_id',
        localField: 'posts'
      }
    }
  }
});
store.defineMapper('profile', {
  relations: {
    // Profile belongsTo User
    belongsTo: {
      user: {
        foreignKey: 'user_id',
        localField: 'user'
      }
    }
  }
});
store.defineMapper('user', {
  relations: {
    // User hasOne Profile
    hasOne: {
      post: {
        foreignKey: 'user_id',
        localField: 'profile'
      }
    }
  }
});

hasMany relationship

The hasMany relationship defines a parent -> child relationship, where the parent zero or more children. hasMany relationships can be defined 3 ways: foreignKey, localKeys, and foreignKeys.

foreignKey

Defining a hasMany relationship using the foreignKey option means that the child records each have a field whose value is the primary key of the parent. A hasMany relationship that uses the foreignKey option is usually paired with a belongsTo relationship.

Example

store.defineMapper('user', {
  relations: {
    // User hasMany Post
    hasMany: {
      post: {
        foreignKey: 'user_id',
        localField: 'posts'
      }
    }
  }
});
store.defineMapper('post', {
  relations: {
    // Post belongsTo User
    belongsTo: {
      user: {
        foreignKey: 'user_id',
        localField: 'user'
      }
    }
  }
});

localKeys

Defining a hasMany relationship using the localKeys option means that the parent record has a field whose value is an array of the primary keys of the parent's child records. A hasMany relationship that uses the localKeys option is usually paired with another hasMany relationship that uses the foreignKeys option.

Example

store.defineMapper('user', {
  relations: {
    // User hasMany Post
    hasMany: {
      post: {
        localKeys: 'post_ids',
        localField: 'posts'
      }
    }
  }
});
store.defineMapper('post', {
  relations: {
    // Post hasMany User
    hasMany: {
      user: {
        foreignKeys: 'post_ids',
        localField: 'users'
      }
    }
  }
});

foreignKeys

Defining a hasMany relationship using the foreignKeys option means that the child records do not have references to their parent records, because the parent records have a field whose value is an array for the primary keys of its child records. hasMany relationships that use the foreignKeys option are usually paired with a hasMany relationship that uses the localKeys option.

Example

store.defineMapper('post', {
  relations: {
    // Post hasMany User
    hasMany: {
      user: {
        foreignKeys: 'post_ids',
        localField: 'users'
      }
    }
  }
});
store.defineMapper('user', {
  relations: {
    // User hasMany Post
    hasMany: {
      post: {
        localKeys: 'post_ids',
        localField: 'posts'
      }
    }
  }
});

hasOne relationship

The hasOne relationship defines a parent -> child relationship, where the parent has a single child. A hasOne relationship is usually paired with a belongsTo relationship.

Example

store.defineMapper('user', {
  relations: {
    // User hasOne Profile
    hasOne: {
      post: {
        foreignKey: 'user_id',
        localField: 'profile'
      }
    }
  }
});
store.defineMapper('profile', {
  relations: {
    // Profile belongsTo User
    belongsTo: {
      user: {
        foreignKey: 'user_id',
        localField: 'user'
      }
    }
  }
});

Eagerly loading relations

Eager-loading relations is done via a with option passed to find or findAll. The with option should be an array of strings, which are either the name of a related Mapper, or the localField where a relation is defined. To load deeper relations, use a period, e.g. posts.comments. In order for loading deep relations to work, you must also include the intermediate relations in the array of strings, e.g. ['posts', 'posts.comments'].

Examples

store.find('user', 123, { with: ['post', 'profile'] })
  .then((user) => {
    console.log(user); // { id: 123, ... }
    console.log(user.profile); // { id: 345, user_id: 123, ... }
    console.log(user.posts); // [{ id: 5765, user_id: 123, ... }, ...]
  });
store.findAll('user', { status: 'active' }, { with: ['posts', 'post.comments', 'profile'] })
  .then((users) => {
    console.log(users[0]); // { id: 123, ... }
    console.log(users[0].profile); // { id: 345, user_id: 123, ... }
    console.log(users[0].posts); // [{ id: 5765, user_id: 123, ... }, ...]
    console.log(users[0].posts[0].comments); // [{ id: 74756, post_id: 5765, ... }, ...]
  });
store.find('post', 5765, { with: ['user', 'user.profile', 'comments'] })
  .then((post) => {
    console.log(post); // { id: 5765, user_id: 123, ... }
    console.log(post.user); // { id: 123, ... }
    console.log(post.user.profile); // { id: 345, user_id: 123, ... }
    console.log(post.comments); // [{ id: 74564, post_id: 5765, ... }, ...]
  });

Lazily loading relations

Loading relations in a lazy fashion can easily be done by making your own find and findAll calls. That's essentially what JSData does with the loadRelations convenience method. Record#loadRelations is a convenience method that lazily loads a record's relations.

Examples

store.find('user', 123)
  .then((user) => {
    return store.find('post', { user_id: user.id });
  })
  .then((posts) => {
    return Promise.all(posts.map((post) => store.findAll('comment', { post_id: post.id })));
  });
console.log(user); // { id: 123, ... }
console.log(user.profile); // undefined
console.log(user.posts); // undefined

user.loadRelations(['posts', 'posts.comments', 'profile']).then((user) => {
  console.log(user); // { id: 123, ... }
  console.log(user.profile); // { id: 345, user_id: 123, ... }
  console.log(user.posts); // [{ id: 5765, user_id: 123, ... }, ...]
  console.log(user.posts[0].comments); // [{ id: 74564, post_id: 5765, ... }, ...]
});

Deep-creating relations

Sometimes you need to create multiple related records all at once. Here's an example of doing it manually:

const postProps = {
  title: 'Relations'
};
const commentProps = [
  {
	  content: 'Awesome!'
  },
  {
	  content: 'So cool!'
  }
];

store.create('post', postProps)
  .then((post) => {
    commentProps.forEach((comment) => {
    	comment.post_id = post.id;
    });
  
		return store.createMany('comment', commentProps);
  })
  .then((comments) => {
		// done
	});

Mapper#create and Mapper#createMany support shorthands for this:

const postProps = {
  title: 'Relations',
  comments: [
    {
      content: 'Awesome!'
    },
    {
      content: 'So cool!'
    }
  ]
};

store.create('post', postProps, { with: ['comments'] })
  .then((post) => {
  	// done
  
		post.id === post.comments[0].post_id; // true!
  });

Using the with option in the context of create or createMany results in a cascading create if the props contain embedded relations. A cascading create means JSData will be making multiple create/createMany calls for you.

❗️

Caution

Cascading creates are NOT performed in a transaction. You'll have to do that yourself.

In the case of the HTTP adapter, you might want to send the embedded relations to your server instead of JSData making multiple create/createMany calls for you. In this case use the pass option:

const postProps = {
  title: 'Relations',
  comments: [
    {
      content: 'Awesome!'
    },
    {
      content: 'So cool!'
    }
  ]
};

store.create('post', postProps, { pass: ['comments'] })
  .then((post) => {
  	// done
  
		post.id === post.comments[0].post_id; // true!
  });

And then on the server you can sort out the embedded relations and do the cascading create calls yourself.

Next steps: Working with the DataStore

Move on to part 8 of the tutorial: Working with the DataStore

🚧

See an issue with this tutorial?

You can open an issue or better yet, suggest edits right on this page.

πŸ“˜

Need support?

Have a technical question? Post on the js-data Stack Overflow channel or the Mailing list.

Want to chat with the community? Hop onto the js-data Slack channel.


What’s Next

Continue with part 8 of the Tutorial or explore other documentation.