Relationships

Model relations between documents using helpers, similar to ORMs.

What are Relationships?

Relationships define how documents in different collections relate to each other. They allow you to easily access related data without writing complex queries. arango-typed provides ORM-like relationship helpers similar to Mongoose, Sequelize, and TypeORM.

Relationships help you:

  • Access Related Data: Easily get related documents without manual queries
  • Maintain Data Integrity: Ensure relationships are properly maintained
  • Simplify Queries: Avoid writing complex AQL joins manually
  • Type Safety: TypeScript support for relationship access
  • Populate Data: Load related documents in a single operation

Relationship Types

arango-typed supports four main relationship types:

  • HasOne: One-to-one relationship (e.g., User has one Profile)
  • HasMany: One-to-many relationship (e.g., User has many Posts)
  • BelongsTo: Many-to-one relationship (e.g., Post belongs to User)
  • BelongsToMany: Many-to-many relationship (e.g., User belongs to many Roles)
  • Polymorphic: A relationship that can reference multiple model types

HasOne (One-to-One)

A HasOne relationship means that one document has exactly one related document. For example, a User has one Profile.

Defining HasOne

import { Schema, model } from 'arango-typed';
import { HasOne } from 'arango-typed';

const UserSchema = new Schema({
  name: String,
  email: String
});

const ProfileSchema = new Schema({
  bio: String,
  avatar: String,
  userId: String  // Foreign key
});

const User = model('users', UserSchema);
const Profile = model('profiles', ProfileSchema);

// Define relationship: User has one Profile
const userProfileRelation = new HasOne(User, Profile, {
  localField: '_id',      // User's _id field
  foreignField: 'userId'  // Profile's userId field
});

Using HasOne

// Get user
const user = await User.findById('users/123');

// Get related profile
const profile = await userProfileRelation.getRelated(user);
if (profile) {
  console.log(profile.bio);
}

// Associate a profile with a user
const newProfile = await Profile.create({
  bio: 'Software developer',
  avatar: 'avatar.jpg'
});
await userProfileRelation.associate(user, newProfile);

// Disassociate profile
await userProfileRelation.disassociate(user);

HasOne Example: User and Profile

// User has one Profile
const user = await User.create({
  name: 'John Doe',
  email: 'john@example.com'
});

const profile = await Profile.create({
  bio: 'Software developer',
  avatar: 'avatar.jpg',
  userId: user._id  // Link to user
});

// Later, get user's profile
const userProfile = await Profile.findOne({ userId: user._id });
console.log(userProfile.bio);

HasMany (One-to-Many)

A HasMany relationship means that one document can have many related documents. For example, a User has many Posts.

Defining HasMany

const PostSchema = new Schema({
  title: String,
  content: String,
  userId: String  // Foreign key
});

const Post = model('posts', PostSchema);

// Define relationship: User has many Posts
const userPostsRelation = new HasMany(User, Post, {
  localField: '_id',      // User's _id field
  foreignField: 'userId'  // Post's userId field
});

Using HasMany

// Get user
const user = await User.findById('users/123');

// Get all user's posts
const posts = await userPostsRelation.getRelated(user);
posts.forEach(post => {
  console.log(post.title);
});

// Associate posts with user
const post1 = await Post.create({ title: 'Post 1', content: '...' });
const post2 = await Post.create({ title: 'Post 2', content: '...' });
await userPostsRelation.associate(user, [post1, post2]);

// Disassociate specific posts
await userPostsRelation.disassociate(user, [post1]);

// Disassociate all posts
await userPostsRelation.disassociate(user);

HasMany Example: User and Posts

// User has many Posts
const user = await User.create({
  name: 'John Doe',
  email: 'john@example.com'
});

// Create posts for the user
const post1 = await Post.create({
  title: 'My First Post',
  content: 'This is my first post',
  userId: user._id
});

const post2 = await Post.create({
  title: 'My Second Post',
  content: 'This is my second post',
  userId: user._id
});

// Get all user's posts
const userPosts = await Post.find({ userId: user._id }).all();
console.log(`User has ${userPosts.length} posts`);

BelongsTo (Many-to-One)

A BelongsTo relationship means that many documents belong to one document. For example, many Posts belong to one User. This is the inverse of HasMany.

Defining BelongsTo

// Post belongs to User
const postUserRelation = new BelongsTo(Post, User, {
  foreignField: 'userId',  // Post's userId field
  localField: '_id'        // User's _id field
});

Using BelongsTo

// Get post
const post = await Post.findById('posts/456');

// Get post's author (user)
const author = await postUserRelation.getRelated(post);
if (author) {
  console.log(author.name);
}

// Associate post with user
const user = await User.findById('users/123');
await postUserRelation.associate(post, user);

// Disassociate post from user
await postUserRelation.disassociate(post);

BelongsTo Example: Post and User

// Post belongs to User
const user = await User.create({
  name: 'John Doe',
  email: 'john@example.com'
});

const post = await Post.create({
  title: 'My Post',
  content: 'Post content',
  userId: user._id  // Reference to user
});

// Get post's author
const postAuthor = await User.findById(post.userId);
console.log(postAuthor.name);

BelongsToMany (Many-to-Many)

A BelongsToMany relationship means that many documents can belong to many other documents. In ArangoDB, you can use either a junction collection (document collection) or graph edges (edge collection) to store relationships. Using graph edges is recommended as it provides better performance and enables graph features like traversals and path queries.

Option 1: Using Graph Edges (Recommended)

Use graph edges for many-to-many relationships. This is the native ArangoDB approach and provides better performance:

import { getDatabase } from 'arango-typed';

const db = getDatabase();

// First, create a graph with the edge collection
await db.createGraph('app_graph', {
  edgeDefinitions: [
    {
      collection: 'user_roles',  // Edge collection
      from: ['users'],
      to: ['roles']
    }
  ]
});

const RoleSchema = new Schema({
  name: String,
  permissions: Array
});

const Role = model('roles', RoleSchema);

// Define relationship using graph edges
const userRolesRelation = new BelongsToMany(User, Role, {
  through: 'user_roles',           // Edge collection name
  useGraph: true,                  // Use graph edges
  graphName: 'app_graph',          // Graph name
  direction: 'outbound',           // Direction: 'outbound' | 'inbound' | 'any'
  localField: '_id',               // User's _id field
  foreignField: '_id'              // Role's _id field
});

Option 2: Using Junction Collection (Backward Compatible)

Use a document collection as a junction table. This approach is backward compatible but less efficient:

const RoleSchema = new Schema({
  name: String,
  permissions: Array
});

const Role = model('roles', RoleSchema);

// Define relationship using junction collection
const userRolesRelation = new BelongsToMany(User, Role, {
  through: 'user_roles',           // Document collection (junction table)
  useGraph: false,                  // Use junction table (default)
  localKey: 'user_id',             // Field in junction collection
  foreignKey: 'role_id',            // Field in junction collection
  localField: '_id',                // User's _id field
  foreignField: '_id'               // Role's _id field
});

Using BelongsToMany

// Get user
const user = await User.findById('users/123');

// Get user's roles
const roles = await userRolesRelation.getRelated(user);
roles.forEach(role => {
  console.log(role.name);
});

// Associate roles with user
const adminRole = await Role.create({ name: 'admin' });
const userRole = await Role.create({ name: 'user' });
await userRolesRelation.associate(user, [adminRole, userRole]);

// Disassociate specific roles
await userRolesRelation.disassociate(user, [userRole]);

// Disassociate all roles
await userRolesRelation.disassociate(user);

BelongsToMany Example: Users and Roles (Graph Edges)

Example using graph edges (recommended approach):

import { connect, getDatabase, model, Schema, BelongsToMany } from 'arango-typed';

// Connect and create graph
const db = getDatabase();

// Create graph with edge collection
await db.createGraph('app_graph', {
  edgeDefinitions: [
    {
      collection: 'user_roles',
      from: ['users'],
      to: ['roles']
    }
  ]
});

// Define schemas
const UserSchema = new Schema({
  name: String,
  email: String
});

const RoleSchema = new Schema({
  name: String,
  permissions: Array
});

const User = model('users', UserSchema);
const Role = model('roles', RoleSchema);

// Define relationship using graph edges
const userRolesRelation = new BelongsToMany(User, Role, {
  through: 'user_roles',
  useGraph: true,
  graphName: 'app_graph',
  direction: 'outbound'
});

// Create users and roles
const user = await User.create({ name: 'John', email: 'john@example.com' });
const adminRole = await Role.create({ name: 'admin', permissions: ['all'] });
const editorRole = await Role.create({ name: 'editor', permissions: ['edit'] });

// Associate roles with user (creates edges automatically)
await userRolesRelation.associate(user, [adminRole, editorRole]);

// Get user's roles
const roles = await userRolesRelation.getRelated(user);
console.log(`User has ${roles.length} roles`);
roles.forEach(role => console.log(role.name));

// Disassociate a role
await userRolesRelation.disassociate(user, [editorRole]);

BelongsToMany Example: Users and Roles (Junction Collection)

Example using junction collection (backward compatible):

// Define relationship using junction collection
const userRolesRelation = new BelongsToMany(User, Role, {
  through: 'user_roles',      // Document collection
  useGraph: false,             // Use junction table
  localKey: 'user_id',
  foreignKey: 'role_id'
});

// Create users and roles
const user = await User.create({ name: 'John', email: 'john@example.com' });
const adminRole = await Role.create({ name: 'admin', permissions: ['all'] });
const editorRole = await Role.create({ name: 'editor', permissions: ['edit'] });

// Associate roles with user
await userRolesRelation.associate(user, [adminRole, editorRole]);

// Get user's roles
const roles = await userRolesRelation.getRelated(user);
console.log(`User has ${roles.length} roles`);

// Or manually create junction entries
const db = getDatabase();
const userRoles = db.collection('user_roles');

await userRoles.save({
  user_id: user._id,
  role_id: adminRole._id
});

await userRoles.save({
  user_id: user._id,
  role_id: editorRole._id
});

// Query user's roles
const cursor = await db.query(`
  FOR junction IN user_roles
  FILTER junction.user_id == @userId
  FOR role IN roles
  FILTER role._id == junction.role_id
  RETURN role
`, { userId: user._id });

const userRolesList = await cursor.all();
console.log(`User has ${userRolesList.length} roles`);

Benefits of Using Graph Edges

  • Better Performance: Edges are optimized for relationship queries
  • Graph Features: Can use traversals, path queries, and graph algorithms
  • Native Support: Edges are first-class citizens in ArangoDB
  • Simpler Queries: No need for JOIN operations
  • Type Safety: Edge collections enforce _from and _to structure

Polymorphic Relations

Polymorphic relationships allow a model to belong to more than one other model on a single association. For example, a Comment can belong to either a Post or a Video.

Defining Polymorphic

const CommentSchema = new Schema({
  content: String,
  commentableType: String,  // 'Post' or 'Video'
  commentableId: String     // ID of the related document
});

const Comment = model('comments', CommentSchema);
const Post = model('posts', PostSchema);
const Video = model('videos', VideoSchema);

// Define polymorphic relationship
const polymorphicRelation = new PolymorphicRelation(getDatabase(), {
  typeField: 'commentableType',  // Field storing the type
  idField: 'commentableId',      // Field storing the ID
  models: {
    'Post': Post,
    'Video': Video
  }
});

Using Polymorphic

// Create a comment on a post
const post = await Post.create({ title: 'My Post' });
const comment = await Comment.create({
  content: 'Great post!',
  commentableType: 'Post',
  commentableId: post._id
});

// Get the commentable (can be Post or Video)
const commentable = await polymorphicRelation.getRelated(comment);
if (commentable) {
  console.log(commentable.title || commentable.name);
}

// Associate comment with a video
const video = await Video.create({ title: 'My Video' });
await polymorphicRelation.associate(comment, video, 'Video');

Polymorphic Example: Comments

// Comment can belong to Post or Video
const post = await Post.create({ title: 'My Post' });
const video = await Video.create({ title: 'My Video' });

// Comment on post
const postComment = await Comment.create({
  content: 'Nice post!',
  commentableType: 'Post',
  commentableId: post._id
});

// Comment on video
const videoComment = await Comment.create({
  content: 'Great video!',
  commentableType: 'Video',
  commentableId: video._id
});

// Get commentable based on type
const postCommentable = await Post.findById(postComment.commentableId);
const videoCommentable = await Video.findById(videoComment.commentableId);

Populating Relationships

The populate method allows you to load related documents in a single operation, similar to Mongoose's populate.

Basic Population

import { Relation } from 'arango-typed';

// Get post with populated author
const post = await Post.findById('posts/456');
const populatedPost = await Relation.populate(post, {
  path: 'author',
  model: User,
  options: {
    localField: 'userId',
    foreignField: '_id'
  }
});

console.log(populatedPost.author.name);

Multiple Population

// Populate multiple fields
const post = await Post.findById('posts/456');
const populated = await Relation.populate(post, [
  {
    path: 'author',
    model: User,
    options: { localField: 'userId', foreignField: '_id' }
  },
  {
    path: 'category',
    model: Category,
    options: { localField: 'categoryId', foreignField: '_id' }
  }
]);

console.log(populated.author.name);
console.log(populated.category.name);

Nested Population

// Populate nested relationships
const post = await Post.findById('posts/456');

// First populate author
let populated = await Relation.populate(post, {
  path: 'author',
  model: User
});

// Then populate author's profile
populated = await Relation.populate(populated, {
  path: 'profile',
  model: Profile,
  options: { localField: 'author._id', foreignField: 'userId' }
});

console.log(populated.author.profile.bio);

Selecting Fields

// Populate with selected fields only
const post = await Post.findById('posts/456');
const populated = await Relation.populate(post, {
  path: 'author',
  model: User,
  select: ['name', 'email']  // Only get name and email
});

Filtering Populated Data

// Populate with filtering
const user = await User.findById('users/123');
const populated = await Relation.populate(user, {
  path: 'posts',
  model: Post,
  match: { published: true },  // Only get published posts
  options: { localField: '_id', foreignField: 'userId' }
});

Relationship Options

All relationships support various options to customize their behavior:

localField and foreignField

Specify which fields to use for the relationship:

const relation = new HasOne(User, Profile, {
  localField: '_id',      // Field on User model
  foreignField: 'userId'  // Field on Profile model
});

cascade

Automatically delete related documents when parent is deleted:

const relation = new HasMany(User, Post, {
  localField: '_id',
  foreignField: 'userId',
  cascade: true  // Delete posts when user is deleted
});

// When user is deleted, all related posts are automatically deleted
await user.remove();

eager

Automatically load related documents (future feature):

const relation = new HasMany(User, Post, {
  localField: '_id',
  foreignField: 'userId',
  eager: true  // Automatically load posts when loading user
});

Complete Example: Blog Application

Full example of relationships in a blog application:

import { Schema, model, HasOne, HasMany, BelongsTo } from 'arango-typed';

// Schemas
const UserSchema = new Schema({
  name: String,
  email: String
});

const ProfileSchema = new Schema({
  bio: String,
  avatar: String,
  userId: String
});

const PostSchema = new Schema({
  title: String,
  content: String,
  userId: String,
  categoryId: String
});

const CategorySchema = new Schema({
  name: String,
  slug: String
});

const CommentSchema = new Schema({
  content: String,
  postId: String,
  userId: String
});

// Models
const User = model('users', UserSchema);
const Profile = model('profiles', ProfileSchema);
const Post = model('posts', PostSchema);
const Category = model('categories', CategorySchema);
const Comment = model('comments', CommentSchema);

// Define relationships
const userProfile = new HasOne(User, Profile, {
  localField: '_id',
  foreignField: 'userId'
});

const userPosts = new HasMany(User, Post, {
  localField: '_id',
  foreignField: 'userId',
  cascade: true  // Delete posts when user is deleted
});

const postAuthor = new BelongsTo(Post, User, {
  foreignField: 'userId',
  localField: '_id'
});

const postCategory = new BelongsTo(Post, Category, {
  foreignField: 'categoryId',
  localField: '_id'
});

const postComments = new HasMany(Post, Comment, {
  localField: '_id',
  foreignField: 'postId',
  cascade: true
});

// Usage
const user = await User.create({
  name: 'John Doe',
  email: 'john@example.com'
});

const profile = await Profile.create({
  bio: 'Software developer',
  avatar: 'avatar.jpg',
  userId: user._id
});

const category = await Category.create({
  name: 'Technology',
  slug: 'technology'
});

const post = await Post.create({
  title: 'My First Post',
  content: 'Post content',
  userId: user._id,
  categoryId: category._id
});

const comment = await Comment.create({
  content: 'Great post!',
  postId: post._id,
  userId: user._id
});

// Get related data
const userProfileData = await userProfile.getRelated(user);
const userPostsData = await userPosts.getRelated(user);
const postAuthorData = await postAuthor.getRelated(post);
const postCategoryData = await postCategory.getRelated(post);
const postCommentsData = await postComments.getRelated(post);

// Use populate for multiple relationships
const populatedPost = await Relation.populate(post, [
  {
    path: 'author',
    model: User,
    options: { localField: 'userId', foreignField: '_id' }
  },
  {
    path: 'category',
    model: Category,
    options: { localField: 'categoryId', foreignField: '_id' }
  },
  {
    path: 'comments',
    model: Comment,
    options: { localField: '_id', foreignField: 'postId', justOne: false }
  }
]);

console.log(populatedPost.author.name);
console.log(populatedPost.category.name);
console.log(populatedPost.comments.length);

Best Practices

  • Index Foreign Keys: Always create indexes on foreign key fields for performance
  • Use Cascade Carefully: Only enable cascade delete when you're sure you want automatic deletion
  • Populate Selectively: Only populate relationships you actually need to avoid N+1 queries
  • Use BelongsToMany for Many-to-Many: Use junction collections for many-to-many relationships
  • Validate Relationships: Ensure foreign keys exist before creating relationships
  • Handle Null Relationships: Always check if related documents exist before accessing them
  • Use TypeScript: Define TypeScript interfaces for relationship types
  • Document Relationships: Document your relationship structure for team members
  • Test Relationships: Write tests to verify relationships work correctly
  • Consider Performance: Use populate sparingly and consider caching for frequently accessed relationships

Performance Considerations

  • Index Foreign Keys: Create indexes on all foreign key fields
  • Avoid N+1 Queries: Use populate to load multiple relationships at once
  • Select Only Needed Fields: Use select when populating to reduce data transfer
  • Cache Relationships: Cache frequently accessed relationships
  • Batch Operations: When possible, batch relationship queries

Common Patterns

Pattern 1: User with Profile and Posts

// User has one Profile and many Posts
const user = await User.findById('users/123');

// Get profile
const profile = await Profile.findOne({ userId: user._id });

// Get posts
const posts = await Post.find({ userId: user._id }).all();

// Or use populate
const populated = await Relation.populate(user, [
  {
    path: 'profile',
    model: Profile,
    options: { localField: '_id', foreignField: 'userId', justOne: true }
  },
  {
    path: 'posts',
    model: Post,
    options: { localField: '_id', foreignField: 'userId', justOne: false }
  }
]);

Pattern 2: Post with Author and Comments

// Post belongs to User and has many Comments
const post = await Post.findById('posts/456');

const populated = await Relation.populate(post, [
  {
    path: 'author',
    model: User,
    options: { localField: 'userId', foreignField: '_id', justOne: true }
  },
  {
    path: 'comments',
    model: Comment,
    options: { localField: '_id', foreignField: 'postId', justOne: false }
  }
]);

Pattern 3: Many-to-Many with Junction

// Users and Roles many-to-many
const user = await User.findById('users/123');

// Get user's roles through junction collection
const db = getDatabase();
const cursor = await db.query(`
  FOR junction IN user_roles
  FILTER junction.user_id == @userId
  FOR role IN roles
  FILTER role._id == junction.role_id
  RETURN role
`, { userId: user._id });

const roles = await cursor.all();
📚 API Reference: For complete API documentation including all methods and TypeScript types, see Relations Module API Reference.
Next: Learn about OGM (Graphs) for graph database operations.