Skip to the content.

Relationships

Firebase ORM provides comprehensive support for one-to-one, one-to-many, and many-to-many relationships through decorators. This allows you to model complex data relationships while maintaining the flexibility of Firestore’s NoSQL structure.

Setting Up Relationships

Prerequisites

Before defining relationships, ensure you have:

  1. Imported the relationship decorators:
    import { BelongsTo, HasOne, HasMany, BelongsToMany } from "@arbel/firebase-orm";
    
  2. Defined your models with proper @Model and @Field decorators
  3. Planned your database structure with clear foreign key patterns

Database Structure Considerations

When designing relationships in Firebase ORM:

Relationship Types Overview

Relationship When to Use Example
@BelongsTo This model has a foreign key pointing to another User Profile → User
@HasOne Another model has a foreign key pointing to this User → User Profile
@HasMany Another model has multiple records pointing to this User → Posts
@BelongsToMany Many-to-many through junction table User ↔ Roles

One-to-One Relationships

One-to-one relationships connect exactly one record in one collection to exactly one record in another collection.

BelongsTo (this model has the foreign key)

Use @BelongsTo when this model stores a foreign key pointing to another model.

Database Structure:

users/
  user-1: { name: "John Doe" }
  
user_profiles/
  profile-1: { user_id: "user-1", bio: "Software developer" }

Setup:

import { Field, BaseModel, Model, BelongsTo } from "@arbel/firebase-orm";

// First, define the target model (User)
@Model({
  reference_path: 'users',
  path_id: 'user_id'
})
class User extends BaseModel {
  @Field({ is_required: true })
  public name!: string;
}

// Then, define the model with the foreign key (UserProfile)
@Model({
  reference_path: 'user_profiles',
  path_id: 'profile_id'
})
class UserProfile extends BaseModel {
  @Field({ is_required: true, field_name: 'user_id' })
  public userId!: string;  // This is the foreign key

  @Field({ is_required: false })
  public bio?: string;

  // Define the relationship
  @BelongsTo({
    model: User,           // The target model class
    localKey: 'userId'     // The local field containing the foreign key
  })
  public user?: User;      // Optional property to hold the loaded relationship
}

Usage:

// Load a profile and its related user
const profile = new UserProfile();
await profile.load('profile-1');

// Method 1: Load the relationship explicitly
const user = await profile.loadBelongsTo('user');
console.log(user.name); // "John Doe"

// Method 2: Access the loaded relationship (after loading)
await profile.loadWithRelationships(['user']);
console.log(profile.user?.name); // "John Doe"

HasOne (other model has the foreign key)

Use @HasOne when another model has a foreign key pointing to this model.

Database Structure:

users/
  user-1: { name: "John Doe" }
  
user_profiles/
  profile-1: { user_id: "user-1", bio: "Software developer" }

Setup:

@Model({
  reference_path: 'users',
  path_id: 'user_id'
})
class User extends BaseModel {
  @Field({ is_required: true })
  public name!: string;

  // Define the relationship
  @HasOne({
    model: UserProfile,     // The model that has the foreign key
    foreignKey: 'user_id'   // The field in UserProfile that references this User
  })
  public profile?: UserProfile;
}

Usage:

// Load a user and their profile
const user = new User();
await user.load('user-1');

// Load the related profile
const profile = await user.loadHasOne('profile');
console.log(profile.bio); // "Software developer"

One-to-Many Relationships

One-to-many relationships connect one record to multiple related records. This is common for parent-child relationships like User → Posts or Category → Products.

Database Structure:

users/
  user-1: { name: "John Doe" }
  user-2: { name: "Jane Smith" }

posts/
  post-1: { title: "First Post", author_id: "user-1" }
  post-2: { title: "Second Post", author_id: "user-1" }
  post-3: { title: "Jane's Post", author_id: "user-2" }

Setup:

// Define the "many" side (Post belongs to User)
@Model({
  reference_path: 'posts',
  path_id: 'post_id'
})
class Post extends BaseModel {
  @Field({ is_required: true })
  public title!: string;

  @Field({ is_required: true, field_name: 'author_id' })
  public authorId!: string;  // Foreign key to User

  // Each post belongs to one user
  @BelongsTo({
    model: User,
    localKey: 'authorId'
  })
  public author?: User;
}

// Define the "one" side (User has many Posts)
@Model({
  reference_path: 'users',
  path_id: 'user_id'
})
class User extends BaseModel {
  @Field({ is_required: true })
  public name!: string;

  // User has many posts
  @HasMany({
    model: Post,              // The model that contains the foreign key
    foreignKey: 'author_id'   // The field in Post that references this User
  })
  public posts?: Post[];
}

Usage:

// Load a user and their posts
const user = new User();
await user.load('user-1');

// Load all posts by this user
const posts = await user.loadHasMany('posts');
console.log(posts.length); // 2
console.log(posts[0].title); // "First Post"

// Load a post and its author
const post = new Post();
await post.load('post-1');

const author = await post.loadBelongsTo('author');
console.log(author.name); // "John Doe"

// Load both relationships at once
await user.loadWithRelationships(['posts']);
await post.loadWithRelationships(['author']);

Many-to-Many Relationships

Many-to-many relationships connect multiple records from one collection to multiple records in another collection. This requires a junction table (also called a pivot table) to store the relationships.

Database Structure:

users/
  user-1: { name: "John Doe" }
  user-2: { name: "Jane Smith" }

roles/
  role-1: { name: "Admin" }
  role-2: { name: "Editor" }
  role-3: { name: "Viewer" }

user_roles/ (junction table)
  ur-1: { user_id: "user-1", role_id: "role-1" }
  ur-2: { user_id: "user-1", role_id: "role-2" }
  ur-3: { user_id: "user-2", role_id: "role-3" }

Setup:

// Step 1: Define the junction table model
@Model({
  reference_path: 'user_roles',
  path_id: 'user_role_id'
})
class UserRole extends BaseModel {
  @Field({ is_required: true, field_name: 'user_id' })
  public userId!: string;

  @Field({ is_required: true, field_name: 'role_id' })
  public roleId!: string;

  // Optional: You can add relationships to the junction table too
  @BelongsTo({ model: User, localKey: 'userId' })
  public user?: User;

  @BelongsTo({ model: Role, localKey: 'roleId' })
  public role?: Role;
}

// Step 2: Define the Role model
@Model({
  reference_path: 'roles',
  path_id: 'role_id'
})
class Role extends BaseModel {
  @Field({ is_required: true })
  public name!: string;

  // Many-to-many: Role belongs to many users
  @BelongsToMany({
    model: User,           // The target model
    through: UserRole,     // The junction table model
    thisKey: 'role_id',    // Field in junction table that references this Role
    otherKey: 'user_id'    // Field in junction table that references the User
  })
  public users?: User[];
}

// Step 3: Define the User model
@Model({
  reference_path: 'users',
  path_id: 'user_id'
})
class User extends BaseModel {
  @Field({ is_required: true })
  public name!: string;

  // Many-to-many: User belongs to many roles
  @BelongsToMany({
    model: Role,           // The target model
    through: UserRole,     // The junction table model
    thisKey: 'user_id',    // Field in junction table that references this User
    otherKey: 'role_id'    // Field in junction table that references the Role
  })
  public roles?: Role[];
}

Usage:

// Load a user and their roles
const user = new User();
await user.load('user-1');

const roles = await user.loadBelongsToMany('roles');
console.log(roles.length); // 2
console.log(roles.map(r => r.name)); // ["Admin", "Editor"]

// Load a role and its users
const role = new Role();
await role.load('role-1');

const users = await role.loadBelongsToMany('users');
console.log(users.length); // 1
console.log(users[0].name); // "John Doe"

// Create new many-to-many relationships
const newUser = new User();
newUser.name = "Bob Wilson";
await newUser.save();

const adminRole = new Role();
await adminRole.load('role-1'); // Load existing Admin role

// Create the junction record
const userRole = new UserRole();
userRole.userId = newUser.getId();
userRole.roleId = adminRole.getId();
await userRole.save();

Advanced Relationship Loading

Loading Multiple Relationships

You can load all relationships at once using loadWithRelationships():

const user = new User();
await user.load('user-id');

// Load multiple relationships in one call
await user.loadWithRelationships(['profile', 'posts', 'roles']);

// Now you can access all loaded relationships
console.log(user.profile?.bio);
console.log(user.posts?.length);
console.log(user.roles?.map(r => r.name));

// Or load all relationships (if none specified, loads all defined relationships)
await user.loadWithRelationships();

Relationship Loading Methods Reference

Method Description Returns
loadBelongsTo(name) Load a single related model via foreign key Promise<T & BaseModel>
loadHasOne(name) Load a single related model (reverse foreign key) Promise<T & BaseModel>
loadHasMany(name) Load multiple related models Promise<Array<T & BaseModel>>
loadBelongsToMany(name) Load many-to-many relationships Promise<Array<T & BaseModel>>
loadWithRelationships(names?) Load multiple relationships at once Promise<this>

Error Handling

try {
  const user = new User();
  await user.load('user-id');
  
  const posts = await user.loadHasMany('posts');
  console.log(`User has ${posts.length} posts`);
} catch (error) {
  if (error.message.includes('Relationship not found')) {
    console.log('Relationship not defined on model');
  } else if (error.message.includes('not found')) {
    console.log('Related record not found');
  } else {
    console.error('Unexpected error:', error);
  }
}

Best Practices and Common Patterns

1. Consistent Naming Conventions

// Use consistent field naming patterns
@Field({ field_name: 'user_id' })    // Foreign key fields
public userId!: string;

@Field({ field_name: 'created_at' })  // Timestamp fields
public createdAt!: string;

// Use descriptive relationship names
@HasMany({ model: Post, foreignKey: 'author_id' })
public posts?: Post[];  // Clear what this represents

@BelongsTo({ model: User, localKey: 'authorId' })
public author?: User;   // Singular for one-to-one/many-to-one

2. Model Initialization Order

// Define models in dependency order to avoid circular references
// 1. Base models first (no dependencies)
class User extends BaseModel { /* ... */ }
class Role extends BaseModel { /* ... */ }

// 2. Junction tables next
class UserRole extends BaseModel { /* ... */ }

// 3. Models with relationships last
class Post extends BaseModel {
  @BelongsTo({ model: User, localKey: 'authorId' })
  public author?: User;
}

3. Performance Considerations

// Load relationships only when needed
const users = await User.getAll();

// Avoid N+1 queries - batch load relationships
for (const user of users) {
  await user.loadWithRelationships(['posts']); // Load all at once
}

// Or use specific loading methods
const activePosts = await user.loadHasMany('posts').then(posts => 
  posts.filter(p => p.status === 'active')
);

4. Data Integrity

// Always validate foreign keys before saving
class Post extends BaseModel {
  async save() {
    // Validate author exists before saving
    if (this.authorId) {
      const author = new User();
      try {
        await author.load(this.authorId);
      } catch (error) {
        throw new Error(`Invalid author_id: ${this.authorId}`);
      }
    }
    return super.save();
  }
}

Troubleshooting Common Issues

Issue: “Relationship not found”

// ❌ Wrong: Relationship name doesn't match decorator property
@HasMany({ model: Post, foreignKey: 'author_id' })
public userPosts?: Post[];  // Property name is 'userPosts'

await user.loadHasMany('posts'); // ❌ Looking for 'posts' but property is 'userPosts'

// ✅ Correct: Relationship name matches property name
await user.loadHasMany('userPosts'); // ✅ Matches property name

Issue: “No records found”

// Check if the foreign key relationship is correct
const profile = new UserProfile();
profile.userId = 'wrong-user-id'; // ❌ Non-existent user ID

try {
  const user = await profile.loadBelongsTo('user');
} catch (error) {
  console.log('User not found for profile'); // Handle gracefully
}

Issue: Circular Dependencies

// ❌ Avoid circular imports - define models in separate files
// user.model.ts
import { Post } from './post.model'; // ❌ Circular if Post imports User

// ✅ Use forward references or organize dependencies properly
// base-models.ts - Define all base models
// relationship-models.ts - Add relationships after base definitions

Complete Example: Blog System

Here’s a complete example showing how to set up a blog system with relationships:

import { Field, BaseModel, Model, BelongsTo, HasOne, HasMany, BelongsToMany } from "@arbel/firebase-orm";

// 1. Base Models (no dependencies)
@Model({
  reference_path: 'users',
  path_id: 'user_id'
})
export class User extends BaseModel {
  @Field({ is_required: true })
  public name!: string;

  @Field({ is_required: true })
  public email!: string;

  @Field({ is_required: false, field_name: 'created_at' })
  public createdAt?: string;

  // Relationships
  @HasOne({ model: UserProfile, foreignKey: 'user_id' })
  public profile?: UserProfile;

  @HasMany({ model: Post, foreignKey: 'author_id' })
  public posts?: Post[];

  @HasMany({ model: Comment, foreignKey: 'user_id' })
  public comments?: Comment[];

  @BelongsToMany({
    model: Tag,
    through: UserTag,
    thisKey: 'user_id',
    otherKey: 'tag_id'
  })
  public followedTags?: Tag[];
}

@Model({
  reference_path: 'tags',
  path_id: 'tag_id'
})
export class Tag extends BaseModel {
  @Field({ is_required: true })
  public name!: string;

  @Field({ is_required: false })
  public description?: string;

  // Relationships
  @BelongsToMany({
    model: Post,
    through: PostTag,
    thisKey: 'tag_id',
    otherKey: 'post_id'
  })
  public posts?: Post[];

  @BelongsToMany({
    model: User,
    through: UserTag,
    thisKey: 'tag_id',
    otherKey: 'user_id'
  })
  public followers?: User[];
}

// 2. Junction Tables
@Model({
  reference_path: 'post_tags',
  path_id: 'post_tag_id'
})
export class PostTag extends BaseModel {
  @Field({ is_required: true, field_name: 'post_id' })
  public postId!: string;

  @Field({ is_required: true, field_name: 'tag_id' })
  public tagId!: string;
}

@Model({
  reference_path: 'user_tags',
  path_id: 'user_tag_id'
})
export class UserTag extends BaseModel {
  @Field({ is_required: true, field_name: 'user_id' })
  public userId!: string;

  @Field({ is_required: true, field_name: 'tag_id' })
  public tagId!: string;
}

// 3. Dependent Models
@Model({
  reference_path: 'user_profiles',
  path_id: 'profile_id'
})
export class UserProfile extends BaseModel {
  @Field({ is_required: true, field_name: 'user_id' })
  public userId!: string;

  @Field({ is_required: false })
  public bio?: string;

  @Field({ is_required: false, field_name: 'avatar_url' })
  public avatarUrl?: string;

  @Field({ is_required: false })
  public website?: string;

  // Relationships
  @BelongsTo({ model: User, localKey: 'userId' })
  public user?: User;
}

@Model({
  reference_path: 'posts',
  path_id: 'post_id'
})
export class Post extends BaseModel {
  @Field({ is_required: true })
  public title!: string;

  @Field({ is_required: true })
  public content!: string;

  @Field({ is_required: true, field_name: 'author_id' })
  public authorId!: string;

  @Field({ is_required: false, field_name: 'published_at' })
  public publishedAt?: string;

  @Field({ is_required: false })
  public status?: 'draft' | 'published' | 'archived';

  // Relationships
  @BelongsTo({ model: User, localKey: 'authorId' })
  public author?: User;

  @HasMany({ model: Comment, foreignKey: 'post_id' })
  public comments?: Comment[];

  @BelongsToMany({
    model: Tag,
    through: PostTag,
    thisKey: 'post_id',
    otherKey: 'tag_id'
  })
  public tags?: Tag[];
}

@Model({
  reference_path: 'comments',
  path_id: 'comment_id'
})
export class Comment extends BaseModel {
  @Field({ is_required: true })
  public content!: string;

  @Field({ is_required: true, field_name: 'post_id' })
  public postId!: string;

  @Field({ is_required: true, field_name: 'user_id' })
  public userId!: string;

  @Field({ is_required: false, field_name: 'parent_id' })
  public parentId?: string; // For nested comments

  @Field({ is_required: false, field_name: 'created_at' })
  public createdAt?: string;

  // Relationships
  @BelongsTo({ model: Post, localKey: 'postId' })
  public post?: Post;

  @BelongsTo({ model: User, localKey: 'userId' })
  public user?: User;

  @BelongsTo({ model: Comment, localKey: 'parentId' })
  public parent?: Comment;

  @HasMany({ model: Comment, foreignKey: 'parent_id' })
  public replies?: Comment[];
}

// Usage Examples:

// Create a new blog post with relationships
async function createBlogPost() {
  // 1. Create or load the author
  const author = new User();
  author.name = "John Doe";
  author.email = "john@example.com";
  author.createdAt = new Date().toISOString();
  await author.save();

  // 2. Create the post
  const post = new Post();
  post.title = "Getting Started with Firebase ORM";
  post.content = "This is a comprehensive guide...";
  post.authorId = author.getId();
  post.status = 'published';
  post.publishedAt = new Date().toISOString();
  await post.save();

  // 3. Create some tags
  const jsTag = new Tag();
  jsTag.name = "JavaScript";
  jsTag.description = "JavaScript programming language";
  await jsTag.save();

  const ormTag = new Tag();
  ormTag.name = "ORM";
  ormTag.description = "Object-Relational Mapping";
  await ormTag.save();

  // 4. Associate tags with the post
  const postTag1 = new PostTag();
  postTag1.postId = post.getId();
  postTag1.tagId = jsTag.getId();
  await postTag1.save();

  const postTag2 = new PostTag();
  postTag2.postId = post.getId();
  postTag2.tagId = ormTag.getId();
  await postTag2.save();

  return { post, author, tags: [jsTag, ormTag] };
}

// Load a complete blog post with all relationships
async function loadBlogPostWithRelationships(postId: string) {
  const post = new Post();
  await post.load(postId);

  // Load all relationships
  await post.loadWithRelationships(['author', 'comments', 'tags']);

  // Access the loaded data
  console.log(`Post: ${post.title}`);
  console.log(`Author: ${post.author?.name}`);
  console.log(`Tags: ${post.tags?.map(t => t.name).join(', ')}`);
  console.log(`Comments: ${post.comments?.length} comments`);

  // Load nested relationships for comments
  if (post.comments) {
    for (const comment of post.comments) {
      await comment.loadWithRelationships(['user', 'replies']);
      console.log(`Comment by ${comment.user?.name}: ${comment.content}`);
      
      if (comment.replies?.length) {
        for (const reply of comment.replies) {
          await reply.loadBelongsTo('user');
          console.log(`  Reply by ${reply.user?.name}: ${reply.content}`);
        }
      }
    }
  }

  return post;
}

// Find posts by tag
async function findPostsByTag(tagName: string) {
  // Find the tag
  const tag = await Tag.findOne('name', '==', tagName);
  if (!tag) {
    throw new Error(`Tag '${tagName}' not found`);
  }

  // Load posts with this tag
  const posts = await tag.loadBelongsToMany('posts');
  
  // Load authors for all posts
  for (const post of posts) {
    await post.loadBelongsTo('author');
  }

  return posts;
}

// Get user's blog activity
async function getUserBlogActivity(userId: string) {
  const user = new User();
  await user.load(userId);

  // Load all user's blog-related data
  await user.loadWithRelationships(['profile', 'posts', 'comments', 'followedTags']);

  const activity = {
    user: {
      name: user.name,
      email: user.email,
      bio: user.profile?.bio,
      avatar: user.profile?.avatarUrl
    },
    stats: {
      postsCount: user.posts?.length || 0,
      commentsCount: user.comments?.length || 0,
      followedTagsCount: user.followedTags?.length || 0
    },
    recentPosts: user.posts?.slice(0, 5).map(p => ({
      title: p.title,
      status: p.status,
      publishedAt: p.publishedAt
    })),
    followedTags: user.followedTags?.map(t => t.name)
  };

  return activity;
}

Legacy Relationship Methods

For backward compatibility, the original relationship methods are still available:

// Load one related model (assumes foreign key pattern)
const relatedModel = await model.getOneRel(RelatedModel);

// Load many related models (assumes foreign key pattern)  
const relatedModels = await model.getManyRel(RelatedModel);

Note: These legacy methods use automatic foreign key detection based on model names and may not work reliably with complex relationships. The new decorator-based approach is recommended for all new projects.