Skip to content

Custom Repositories ​

PGRestify supports custom repositories that extend the CustomRepositoryBase class with domain-specific methods and business logic. This allows you to encapsulate complex queries, add validation logic, and create reusable components that follow the repository pattern established by ORM.

Overview ​

Custom repositories in PGRestify provide:

  • Domain-Specific Methods: Add business logic methods to repositories
  • Query Encapsulation: Hide complex query logic behind simple method names
  • Type Safety: Full TypeScript support with generic type parameters
  • Inheritance Support: Extend base repository with custom functionality
  • DataManager Integration: Seamless integration with the DataManager pattern
  • Decorator Support: Optional EntityRepository decorator for clean syntax

Creating Custom Repositories ​

Basic Custom Repository ​

Extend the base Repository class with custom methods:

typescript
import { CustomRepositoryBase, createClient } from '@webcoded/pgrestify';

interface User {
  id: string;
  email: string;
  first_name: string;
  last_name: string;
  active: boolean;
  role: 'user' | 'admin' | 'moderator';
  last_login?: string;
  created_at: string;
  updated_at: string;
}

class UserRepository extends CustomRepositoryBase<User> {
  /**
   * Find active users only
   */
  async findActiveUsers(): Promise<User[]> {
    return this.findBy({ active: true });
  }
  
  /**
   * Find user by email address
   */
  async findByEmail(email: string): Promise<User | null> {
    return this.findOne({ email });
  }
  
  /**
   * Find users by role
   */
  async findByRole(role: User['role']): Promise<User[]> {
    return this.findBy({ role, active: true });
  }
  
  /**
   * Find recently active users
   */
  async findRecentlyActive(days: number = 30): Promise<User[]> {
    const cutoffDate = new Date();
    cutoffDate.setDate(cutoffDate.getDate() - days);
    
    return this.createQueryBuilder()
      .where('active = :active', { active: true })
      .andWhere('last_login >= :cutoff', { cutoff: cutoffDate.toISOString() })
      .orderBy('last_login', 'DESC')
      .getMany();
  }
  
  /**
   * Get user statistics
   */
  async getUserStats(): Promise<{
    total: number;
    active: number;
    inactive: number;
    byRole: Record<string, number>;
  }> {
    const allUsers = await this.find();
    const activeUsers = allUsers.filter(u => u.active);
    const inactiveUsers = allUsers.filter(u => !u.active);
    
    const byRole = allUsers.reduce((acc, user) => {
      acc[user.role] = (acc[user.role] || 0) + 1;
      return acc;
    }, {} as Record<string, number>);
    
    return {
      total: allUsers.length,
      active: activeUsers.length,
      inactive: inactiveUsers.length,
      byRole
    };
  }
  
  /**
   * Create user with validation
   */
  async createUser(userData: Omit<User, 'id' | 'created_at' | 'updated_at'>): Promise<User> {
    // Validation
    if (!userData.email || !userData.email.includes('@')) {
      throw new Error('Valid email address is required');
    }
    
    if (!userData.first_name || !userData.last_name) {
      throw new Error('First name and last name are required');
    }
    
    // Check for duplicate email
    const existingUser = await this.findByEmail(userData.email);
    if (existingUser) {
      throw new Error('User with this email already exists');
    }
    
    // Create user with timestamps
    const newUser = {
      ...userData,
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString()
    };
    
    const created = await this.insert(newUser);
    return created[0];
  }
  
  /**
   * Update user's last login
   */
  async updateLastLogin(userId: string): Promise<User | null> {
    const updated = await this.update(
      { id: userId },
      { 
        last_login: new Date().toISOString(),
        updated_at: new Date().toISOString()
      }
    );
    
    return updated[0] || null;
  }
  
  /**
   * Deactivate user
   */
  async deactivateUser(userId: string, reason?: string): Promise<User | null> {
    const updateData: Partial<User> = {
      active: false,
      updated_at: new Date().toISOString()
    };
    
    // If reason is provided, you could store it in a separate audit table
    if (reason) {
      // Log deactivation reason (implement audit logging as needed)
      console.log(`User ${userId} deactivated: ${reason}`);
    }
    
    const updated = await this.update({ id: userId }, updateData);
    return updated[0] || null;
  }
}

// Usage
const client = createClient({ url: 'http://localhost:3000' });

// Get custom repository instance
const userRepository = client.getCustomRepository(UserRepository, 'users');

// Use custom methods
const activeUsers = await userRepository.findActiveUsers();
const adminUsers = await userRepository.findByRole('admin');
const stats = await userRepository.getUserStats();

Advanced Custom Repository ​

Create more complex repositories with relationships and advanced queries:

typescript
interface Post {
  id: string;
  title: string;
  content: string;
  excerpt?: string;
  author_id: string;
  category_id: string;
  published: boolean;
  featured: boolean;
  view_count: number;
  created_at: string;
  updated_at: string;
}

class PostRepository extends CustomRepositoryBase<Post> {
  /**
   * Find published posts with pagination
   */
  async findPublished(options: {
    page?: number;
    limit?: number;
    featured?: boolean;
  } = {}): Promise<{
    posts: Post[];
    pagination: {
      page: number;
      limit: number;
      total: number;
      hasMore: boolean;
    };
  }> {
    const { page = 1, limit = 20, featured } = options;
    const offset = (page - 1) * limit;
    
    let query = this.getQueryBuilder()
      .eq('published', true);
    
    if (featured !== undefined) {
      query = query.eq('featured', featured);
    }
    
    // Get total count for pagination
    const totalCount = await query.getCount();
    
    // Get paginated results
    const result = await query
      .order('created_at', { ascending: false })
      .range(offset, offset + limit - 1)
      .execute();
    
    return {
      posts: result.data || [],
      pagination: {
        page,
        limit,
        total: totalCount,
        hasMore: offset + limit < totalCount
      }
    };
  }
  
  /**
   * Find posts by author with author information
   */
  async findByAuthorWithDetails(authorId: string): Promise<any[]> {
    const result = await this.getQueryBuilder()
      .select(`
        id,
        title,
        excerpt,
        published,
        featured,
        view_count,
        created_at,
        author:users!author_id(
          id,
          first_name,
          last_name,
          email
        ),
        category:categories!category_id(
          id,
          name,
          slug
        )
      `)
      .eq('author_id', authorId)
      .order('created_at', { ascending: false })
      .execute();
    
    return result.data || [];
  }
  
  /**
   * Search posts by title and content
   */
  async searchPosts(query: string, options: {
    publishedOnly?: boolean;
    limit?: number;
  } = {}): Promise<Post[]> {
    const { publishedOnly = true, limit = 50 } = options;
    
    let queryBuilder = this.createQueryBuilder()
      .where('title ILIKE :query', { query: `%${query}%` })
      .orWhere('content ILIKE :query', { query: `%${query}%` });
    
    if (publishedOnly) {
      queryBuilder = queryBuilder.andWhere('published = :published', { published: true });
    }
    
    return queryBuilder
      .orderBy('view_count', 'DESC')
      .limit(limit)
      .getMany();
  }
  
  /**
   * Get popular posts
   */
  async findPopular(limit: number = 10): Promise<Post[]> {
    const result = await this.getQueryBuilder()
      .eq('published', true)
      .order('view_count', { ascending: false })
      .limit(limit)
      .execute();
    
    return result.data || [];
  }
  
  /**
   * Get recent posts by category
   */
  async findRecentByCategory(categoryId: string, limit: number = 5): Promise<Post[]> {
    const result = await this.getQueryBuilder()
      .eq('category_id', categoryId)
      .eq('published', true)
      .order('created_at', { ascending: false })
      .limit(limit)
      .execute();
    
    return result.data || [];
  }
  
  /**
   * Publish post
   */
  async publishPost(postId: string): Promise<Post | null> {
    const updated = await this.update(
      { id: postId },
      { 
        published: true, 
        updated_at: new Date().toISOString() 
      }
    );
    
    return updated[0] || null;
  }
  
  /**
   * Increment view count
   */
  async incrementViewCount(postId: string): Promise<void> {
    // Using raw query to increment atomically
    await this.getQueryBuilder()
      .select('view_count')
      .eq('id', postId)
      .execute()
      .then(async (result) => {
        if (result.data && result.data[0]) {
          const currentCount = result.data[0].view_count || 0;
          await this.update(
            { id: postId },
            { view_count: currentCount + 1 }
          );
        }
      });
  }
  
  /**
   * Get post statistics
   */
  async getPostStats(): Promise<{
    total: number;
    published: number;
    draft: number;
    featured: number;
    totalViews: number;
    avgViews: number;
  }> {
    const allPosts = await this.find();
    const published = allPosts.filter(p => p.published);
    const draft = allPosts.filter(p => !p.published);
    const featured = allPosts.filter(p => p.featured);
    const totalViews = allPosts.reduce((sum, p) => sum + (p.view_count || 0), 0);
    
    return {
      total: allPosts.length,
      published: published.length,
      draft: draft.length,
      featured: featured.length,
      totalViews,
      avgViews: allPosts.length > 0 ? Math.round(totalViews / allPosts.length) : 0
    };
  }
}

Repository with Relationships ​

Handle related data efficiently in custom repositories:

typescript
interface Comment {
  id: string;
  content: string;
  post_id: string;
  author_id: string;
  parent_id?: string;
  approved: boolean;
  created_at: string;
}

class CommentRepository extends Repository<Comment> {
  /**
   * Find comments for a post with author information
   */
  async findByPostWithAuthors(postId: string): Promise<any[]> {
    const result = await this.getQueryBuilder()
      .select(`
        id,
        content,
        created_at,
        approved,
        parent_id,
        author:users!author_id(
          id,
          first_name,
          last_name
        )
      `)
      .eq('post_id', postId)
      .eq('approved', true)
      .order('created_at', { ascending: true })
      .execute();
    
    return result.data || [];
  }
  
  /**
   * Find threaded comments (with replies)
   */
  async findThreadedComments(postId: string): Promise<any[]> {
    const result = await this.getQueryBuilder()
      .select(`
        id,
        content,
        created_at,
        approved,
        parent_id,
        author:users!author_id(
          first_name,
          last_name
        ),
        replies:comments!parent_id(
          id,
          content,
          created_at,
          author:users!author_id(first_name, last_name)
        )
      `)
      .eq('post_id', postId)
      .eq('approved', true)
      .is('parent_id', null) // Only top-level comments
      .order('created_at', { ascending: true })
      .execute();
    
    return result.data || [];
  }
  
  /**
   * Get comment moderation queue
   */
  async getModerationQueue(limit: number = 50): Promise<any[]> {
    const result = await this.getQueryBuilder()
      .select(`
        id,
        content,
        created_at,
        post:posts!post_id(id, title),
        author:users!author_id(
          id,
          first_name,
          last_name,
          email
        )
      `)
      .eq('approved', false)
      .order('created_at', { ascending: true })
      .limit(limit)
      .execute();
    
    return result.data || [];
  }
  
  /**
   * Approve comment
   */
  async approveComment(commentId: string): Promise<Comment | null> {
    const updated = await this.update(
      { id: commentId },
      { approved: true }
    );
    
    return updated[0] || null;
  }
  
  /**
   * Get comment statistics by post
   */
  async getCommentStatsByPost(postId: string): Promise<{
    total: number;
    approved: number;
    pending: number;
  }> {
    const comments = await this.findBy({ post_id: postId });
    const approved = comments.filter(c => c.approved);
    const pending = comments.filter(c => !c.approved);
    
    return {
      total: comments.length,
      approved: approved.length,
      pending: pending.length
    };
  }
}

EntityRepository Decorator ​

Use the EntityRepository decorator for cleaner syntax:

typescript
import { EntityRepository, Repository } from '@webcoded/pgrestify';

@EntityRepository<User>('users')
class DecoratedUserRepository extends Repository<User> {
  async findActiveUsersDecorated(): Promise<User[]> {
    return this.findBy({ active: true });
  }
  
  async findUsersByRoleDecorated(role: User['role']): Promise<User[]> {
    return this.findBy({ role, active: true });
  }
}

// The decorator automatically binds the table name
// Usage remains the same
const decoratedRepo = dataManager.getCustomRepository(DecoratedUserRepository, 'users');
const activeUsers = await decoratedRepo.findActiveUsersDecorated();

Repository Composition ​

Combine multiple repositories for complex operations:

typescript
class BlogService {
  private userRepository: UserRepository;
  private postRepository: PostRepository;
  private commentRepository: CommentRepository;
  
  constructor(dataManager: DataManager) {
    this.userRepository = dataManager.getCustomRepository(UserRepository, 'users');
    this.postRepository = dataManager.getCustomRepository(PostRepository, 'posts');
    this.commentRepository = dataManager.getCustomRepository(CommentRepository, 'comments');
  }
  
  /**
   * Get complete blog post with author and comments
   */
  async getBlogPostComplete(postId: string): Promise<{
    post: Post | null;
    author: User | null;
    comments: Comment[];
    stats: any;
  } | null> {
    // Get post
    const post = await this.postRepository.findById(postId);
    if (!post) return null;
    
    // Get author
    const author = await this.userRepository.findById(post.author_id);
    
    // Get comments
    const comments = await this.commentRepository.findByPostWithAuthors(postId);
    
    // Get stats
    const stats = await this.commentRepository.getCommentStatsByPost(postId);
    
    return {
      post,
      author,
      comments,
      stats
    };
  }
  
  /**
   * Create complete blog post with validation
   */
  async createBlogPost(
    authorId: string, 
    postData: Omit<Post, 'id' | 'author_id' | 'created_at' | 'updated_at'>
  ): Promise<Post> {
    // Validate author exists
    const author = await this.userRepository.findById(authorId);
    if (!author || !author.active) {
      throw new Error('Author not found or inactive');
    }
    
    // Create post
    const newPost = {
      ...postData,
      author_id: authorId,
      view_count: 0,
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString()
    };
    
    const created = await this.postRepository.insert(newPost);
    return created[0];
  }
  
  /**
   * Get user dashboard data
   */
  async getUserDashboard(userId: string): Promise<{
    user: User | null;
    posts: {
      published: Post[];
      drafts: Post[];
      stats: any;
    };
    recentComments: Comment[];
  }> {
    const user = await this.userRepository.findById(userId);
    
    const allUserPosts = await this.postRepository.findBy({ author_id: userId });
    const published = allUserPosts.filter(p => p.published);
    const drafts = allUserPosts.filter(p => !p.published);
    
    const postStats = await this.postRepository.getPostStats();
    
    // Get recent comments on user's posts
    const userPostIds = allUserPosts.map(p => p.id);
    const recentComments = await this.commentRepository
      .getQueryBuilder()
      .in('post_id', userPostIds)
      .eq('approved', true)
      .order('created_at', { ascending: false })
      .limit(10)
      .execute()
      .then(result => result.data || []);
    
    return {
      user,
      posts: {
        published,
        drafts,
        stats: postStats
      },
      recentComments
    };
  }
}

// Usage
const blogService = new BlogService(dataManager);
const dashboard = await blogService.getUserDashboard('user-123');
const completeBlogPost = await blogService.getBlogPostComplete('post-456');

Testing Custom Repositories ​

Create testable custom repositories:

typescript
interface MockRepository<T> {
  find(): Promise<T[]>;
  findBy(where: Partial<T>): Promise<T[]>;
  findOne(where: Partial<T>): Promise<T | null>;
  insert(data: Partial<T>): Promise<T[]>;
  update(where: Partial<T>, data: Partial<T>): Promise<T[]>;
  delete(where: Partial<T>): Promise<T[]>;
}

class TestableUserRepository extends UserRepository {
  private mockData?: User[];
  
  constructor(tableName: string, ...args: any[]) {
    super(tableName, ...args);
  }
  
  // Override for testing
  setMockData(data: User[]): void {
    this.mockData = data;
  }
  
  async find(): Promise<User[]> {
    if (this.mockData) return this.mockData;
    return super.find();
  }
  
  async findBy(where: Partial<User>): Promise<User[]> {
    if (this.mockData) {
      return this.mockData.filter(user => {
        return Object.entries(where).every(([key, value]) => 
          user[key as keyof User] === value
        );
      });
    }
    return super.findBy(where);
  }
}

// Test usage
const testRepo = new TestableUserRepository('users');
testRepo.setMockData([
  { 
    id: '1', 
    email: 'test@example.com', 
    first_name: 'Test', 
    last_name: 'User', 
    active: true, 
    role: 'user',
    created_at: '2024-01-01T00:00:00Z',
    updated_at: '2024-01-01T00:00:00Z'
  }
]);

const activeUsers = await testRepo.findActiveUsers();
console.log('Test result:', activeUsers.length); // 1

Performance Considerations ​

Optimizing Custom Repositories ​

typescript
class OptimizedPostRepository extends PostRepository {
  /**
   * Cached popular posts
   */
  private popularPostsCache?: { posts: Post[]; timestamp: number };
  private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
  
  async findPopularCached(limit: number = 10): Promise<Post[]> {
    const now = Date.now();
    
    if (this.popularPostsCache && 
        (now - this.popularPostsCache.timestamp) < this.CACHE_DURATION) {
      return this.popularPostsCache.posts.slice(0, limit);
    }
    
    const posts = await this.findPopular(limit);
    this.popularPostsCache = {
      posts,
      timestamp: now
    };
    
    return posts;
  }
  
  /**
   * Batch operations for better performance
   */
  async incrementViewCounts(postIds: string[]): Promise<void> {
    // Batch update instead of individual updates
    const promises = postIds.map(async (postId) => {
      const result = await this.getQueryBuilder()
        .select('view_count')
        .eq('id', postId)
        .execute();
      
      if (result.data && result.data[0]) {
        const currentCount = result.data[0].view_count || 0;
        return this.update(
          { id: postId },
          { view_count: currentCount + 1 }
        );
      }
    });
    
    await Promise.all(promises);
  }
}

Summary ​

Custom repositories in PGRestify provide:

  • Domain-Specific Logic: Encapsulate business rules and complex queries
  • Type Safety: Full TypeScript support with generic type parameters
  • Repository Pattern: Familiar ORM-like patterns and methods
  • Relationship Handling: Easy management of related data
  • Service Layer Integration: Compose repositories into higher-level services
  • Testing Support: Mockable interfaces for unit testing
  • Performance Optimization: Caching and batch operation capabilities
  • Decorator Support: Clean syntax with EntityRepository decorator

Custom repositories help organize your data access layer and make your code more maintainable, testable, and type-safe while providing the flexibility to implement domain-specific requirements.

Released under the MIT License.