Relationships & Joins
Master relationship querying in PGRestify with one-to-one, one-to-many, and many-to-many relationships, nested resource fetching, and performance optimization.
Overview
PGRestify leverages PostgREST's powerful relationship querying capabilities to fetch related data in a single request. Instead of multiple round trips to the database, you can retrieve complex nested data structures efficiently through foreign key relationships.
Query Syntax Options
PGRestify supports two main approaches for relationship querying:
1. PostgREST Native Syntax
The traditional string-based select syntax with embedded resources:
const users = await client
.from('users')
.select(`
id,
name,
profile:user_profiles(bio, avatar_url)
`)
.execute();
2. Relations Array Syntax
A more declarative approach using the relations
array:
// Simple relation
const users = await client
.from('users')
.select(['id', 'name', 'profile.bio', 'profile.avatar_url'])
.relations(['profile'])
.execute();
// Multiple relations
const users = await client
.from('users')
.select(['id', 'name', 'posts.title', 'comments.content'])
.relations(['posts', 'comments'])
.execute();
// Nested relations
const users = await client
.from('users')
.select(['id', 'name', 'posts.title', 'posts.comments.content'])
.relations(['posts.comments'])
.execute();
Relations Array Advanced Usage
// Relations with filtering
const activeUsersWithPosts = await client
.from('users')
.select(['id', 'name', 'posts.title', 'posts.created_at'])
.relations(['posts'])
.eq('active', true)
.gte('posts.created_at', '2024-01-01')
.execute();
// Relations with ordering
const usersWithSortedPosts = await client
.from('users')
.select(['id', 'name', 'posts.title', 'posts.upvotes'])
.relations(['posts'])
.order('posts.upvotes', { ascending: false })
.execute();
// Complex nested relations
const fullUserProfile = await client
.from('users')
.select([
'id', 'name', 'email',
'profile.bio', 'profile.avatar_url',
'posts.title', 'posts.content',
'posts.comments.content', 'posts.comments.author.name'
])
.relations(['profile', 'posts.comments.author'])
.execute();
// Relations with aliasing in select
const usersWithAliases = await client
.from('users')
.select([
'id',
'name',
'profile.bio AS userBio',
'posts.title AS postTitle'
])
.relations(['profile', 'posts'])
.execute();
Relationship Types
One-to-One Relationships
Basic One-to-One
// User has one profile
const usersWithProfiles = await client
.from('users')
.select(`
id,
name,
email,
profile:user_profiles(*)
`)
.execute();
// Result structure:
// {
// id: 1,
// name: "John",
// email: "john@example.com",
// profile: {
// bio: "Developer",
// avatar_url: "...",
// birth_date: "1990-01-01"
// }
// }
Specific Profile Fields
// Select only specific profile fields
const users = await client
.from('users')
.select(`
id,
name,
profile:user_profiles(bio, avatar_url)
`)
.execute();
// Aliasing the relationship
const users = await client
.from('users')
.select(`
id,
name,
user_profile:user_profiles(bio, avatar_url)
`)
.execute();
Conditional One-to-One
// Only include profiles that meet certain conditions
const users = await client
.from('users')
.select(`
id,
name,
profile:user_profiles!inner(bio, avatar_url)
`)
.execute(); // Only returns users who have profiles
// With filtering on related table
const users = await client
.from('users')
.select(`
id,
name,
profile:user_profiles(bio, avatar_url)
`)
.eq('user_profiles.is_public', true)
.execute();
One-to-Many Relationships
Basic One-to-Many
// User has many posts
const usersWithPosts = await client
.from('users')
.select(`
id,
name,
posts:posts(
id,
title,
content,
created_at
)
`)
.execute();
// Result structure:
// {
// id: 1,
// name: "John",
// posts: [
// { id: 1, title: "First Post", content: "...", created_at: "..." },
// { id: 2, title: "Second Post", content: "...", created_at: "..." }
// ]
// }
Filtered One-to-Many
// Get users with only their published posts
const usersWithPublishedPosts = await client
.from('users')
.select(`
id,
name,
published_posts:posts(
id,
title,
published_at
)
`)
.eq('posts.status', 'published')
.execute();
// Get users with recent posts only
const recentDate = new Date();
recentDate.setDate(recentDate.getDate() - 30);
const usersWithRecentPosts = await client
.from('users')
.select(`
id,
name,
recent_posts:posts(
id,
title,
created_at
)
`)
.gte('posts.created_at', recentDate.toISOString())
.execute();
Sorted One-to-Many
// Get posts ordered by creation date
const usersWithSortedPosts = await client
.from('users')
.select(`
id,
name,
posts:posts(
id,
title,
created_at
)
`)
.order('posts(created_at)', { ascending: false })
.execute();
// Multiple sort criteria on related data
const usersWithPosts = await client
.from('users')
.select(`
id,
name,
posts:posts(
id,
title,
status,
created_at
)
`)
.order('posts(status)')
.order('posts(created_at)', { ascending: false })
.execute();
Limited One-to-Many
// Get only the latest 5 posts per user
const usersWithLatestPosts = await client
.from('users')
.select(`
id,
name,
latest_posts:posts(
id,
title,
created_at
)
`)
.order('posts(created_at)', { ascending: false })
.limit(5) // Note: This limits the entire query, not per user
.execute();
// For per-user limits, you may need to use RPC functions
const usersWithTopPosts = await client
.rpc('get_users_with_top_posts', {
limit_per_user: 5
})
.execute();
Many-to-Many Relationships
Basic Many-to-Many
// Posts have many tags through post_tags junction table
const postsWithTags = await client
.from('posts')
.select(`
id,
title,
tags:post_tags(
tag:tags(
id,
name,
color
)
)
`)
.execute();
// Flattened structure approach
const postsWithTags = await client
.from('posts')
.select(`
id,
title,
post_tags(
tags(name, color)
)
`)
.execute();
Many-to-Many with Junction Data
// Include data from the junction table
const usersWithRoles = await client
.from('users')
.select(`
id,
name,
user_roles(
assigned_at,
assigned_by,
role:roles(
name,
permissions
)
)
`)
.execute();
// Result includes junction table data:
// {
// id: 1,
// name: "John",
// user_roles: [
// {
// assigned_at: "2024-01-01",
// assigned_by: 123,
// role: { name: "admin", permissions: [...] }
// }
// ]
// }
Filtered Many-to-Many
// Get posts with only active tags
const postsWithActiveTags = await client
.from('posts')
.select(`
id,
title,
post_tags(
tag:tags(name, color)
)
`)
.eq('tags.active', true)
.execute();
// Get users with specific role types
const usersWithAdminRoles = await client
.from('users')
.select(`
id,
name,
user_roles(
role:roles(name, level)
)
`)
.in('roles.type', ['admin', 'moderator'])
.execute();
Nested Resource Fetching
Multi-Level Nesting
// Three levels deep: User -> Posts -> Comments -> Author
const usersWithPostsAndComments = await client
.from('users')
.select(`
id,
name,
posts(
id,
title,
comments(
id,
content,
created_at,
author:users(name, avatar)
)
)
`)
.execute();
Complex Nested Relationships
// E-commerce example: Order with items, products, and customer
const orderDetails = await client
.from('orders')
.select(`
id,
order_number,
status,
created_at,
customer:customers(
id,
name,
email,
shipping_address:addresses(*)
),
order_items(
quantity,
unit_price,
product:products(
name,
description,
category:categories(name)
)
),
payments(
amount,
method,
status
)
`)
.eq('id', orderId)
.single()
.execute();
Selective Deep Fetching
// Fetch different levels of detail based on conditions
const getPostDetails = async (postId: number, includeComments = false) => {
let selectClause = `
id,
title,
content,
author:users(name, avatar),
category:categories(name, slug)
`;
if (includeComments) {
selectClause += `,
comments(
id,
content,
created_at,
author:users(name, avatar)
)`;
}
return client
.from('posts')
.select(selectClause)
.eq('id', postId)
.single()
.execute();
};
Join Types and Behavior
Inner Joins
// Only return users who have profiles (inner join behavior)
const usersWithProfiles = await client
.from('users')
.select(`
id,
name,
profile:user_profiles!inner(bio, avatar_url)
`)
.execute();
// Multiple inner joins
const postsWithAuthorsAndCategories = await client
.from('posts')
.select(`
id,
title,
author:users!inner(name),
category:categories!inner(name)
`)
.execute(); // Only posts that have both author and category
Left Joins (Default)
// Default behavior - includes all users, even without profiles
const allUsersWithOptionalProfiles = await client
.from('users')
.select(`
id,
name,
profile:user_profiles(bio, avatar_url)
`)
.execute();
// Explicitly specify left join
const usersWithOptionalPosts = await client
.from('users')
.select(`
id,
name,
posts:posts!left(title, created_at)
`)
.execute();
Hint-based Joins
// Use hints to optimize join performance
const optimizedQuery = await client
.from('orders')
.select(`
id,
customer:customers!customer_orders_fk(name, email)
`)
.execute();
// Specify relationship name for clarity
const productsWithCategories = await client
.from('products')
.select(`
id,
name,
category:categories!product_category_fk(name)
`)
.execute();
Performance Optimization
Selective Field Loading
// ❌ Bad: Loading all fields from related tables
const inefficient = await client
.from('users')
.select(`
*,
posts(*),
profile(*)
`)
.execute();
// ✅ Good: Only load required fields
const efficient = await client
.from('users')
.select(`
id,
name,
posts(id, title, created_at),
profile(bio, avatar_url)
`)
.execute();
Limit Related Data
// Limit the number of related records
const usersWithRecentPosts = await client
.from('users')
.select(`
id,
name,
recent_posts:posts(
id,
title,
created_at
)
`)
.order('posts(created_at)', { ascending: false })
.limit(10) // Be careful - this limits total results
.execute();
// Better approach for per-user limits
const getUsersWithLimitedPosts = async () => {
// First get users
const users = await client
.from('users')
.select('id, name')
.execute();
// Then get limited posts for each (if needed)
// Or use a custom RPC function
return client
.rpc('get_users_with_limited_posts', { post_limit: 5 })
.execute();
};
Index Considerations
// Ensure foreign keys are indexed for efficient joins
// This is a database design consideration, not query syntax
// Use specific field selection to utilize covering indexes
const optimizedUserPosts = await client
.from('users')
.select(`
id,
name,
posts(id, title, status)
`)
.eq('posts.status', 'published') // If there's an index on status
.execute();
Dynamic Relationship Building
Conditional Relationship Loading
interface QueryOptions {
includePosts?: boolean;
includeProfile?: boolean;
includeComments?: boolean;
}
const buildUserQuery = (options: QueryOptions) => {
let selectClause = 'id, name, email, created_at';
if (options.includeProfile) {
selectClause += ', profile:user_profiles(bio, avatar_url)';
}
if (options.includePosts) {
let postSelect = 'posts(id, title, created_at, status)';
if (options.includeComments) {
postSelect = 'posts(id, title, created_at, status, comments(id, content, author:users(name)))';
}
selectClause += `, ${postSelect}`;
}
return client
.from('users')
.select(selectClause);
};
// Usage
const usersWithPosts = await buildUserQuery({
includePosts: true,
includeProfile: true
}).execute();
Relationship Builder Pattern
class RelationshipBuilder {
private relationships: string[] = [];
withProfile(fields = '*') {
this.relationships.push(`profile:user_profiles(${fields})`);
return this;
}
withPosts(fields = '*', limit?: number) {
let relation = `posts(${fields})`;
this.relationships.push(relation);
return this;
}
withRoles() {
this.relationships.push(`
user_roles(
role:roles(name, permissions),
assigned_at
)
`);
return this;
}
build(baseFields = '*') {
const allSelects = [baseFields, ...this.relationships];
return allSelects.join(', ');
}
}
// Usage
const relationBuilder = new RelationshipBuilder()
.withProfile('bio, avatar_url')
.withPosts('id, title, created_at')
.withRoles();
const users = await client
.from('users')
.select(relationBuilder.build('id, name, email'))
.execute();
Common Relationship Patterns
User Activity Feed
const getUserActivityFeed = async (userId: number) => {
return client
.from('users')
.select(`
id,
name,
activities:user_activities(
id,
type,
created_at,
target_post:posts(id, title),
target_comment:comments(id, content)
)
`)
.eq('id', userId)
.order('user_activities(created_at)', { ascending: false })
.single()
.execute();
};
Product Catalog with Variants
const getProductCatalog = async () => {
return client
.from('products')
.select(`
id,
name,
base_price,
category:categories(name, slug),
variants:product_variants(
id,
sku,
price,
attributes:variant_attributes(
attribute:attributes(name, value)
)
),
images:product_images(url, alt_text, is_primary)
`)
.eq('active', true)
.order('name')
.execute();
};
Forum Thread with Nested Replies
const getThreadWithReplies = async (threadId: number) => {
return client
.from('threads')
.select(`
id,
title,
created_at,
author:users(name, avatar),
posts:thread_posts(
id,
content,
created_at,
author:users(name, avatar),
replies:post_replies(
id,
content,
created_at,
author:users(name, avatar)
)
)
`)
.eq('id', threadId)
.order('thread_posts(created_at)')
.order('post_replies(created_at)')
.single()
.execute();
};
E-commerce Order Summary
const getOrderSummary = async (orderId: number) => {
return client
.from('orders')
.select(`
id,
order_number,
status,
total_amount,
created_at,
customer:customers(
name,
email,
billing_address:addresses!billing_address_id(*),
shipping_address:addresses!shipping_address_id(*)
),
items:order_items(
quantity,
unit_price,
total_price,
product:products(
name,
sku,
image_url
)
),
payments:order_payments(
amount,
method,
status,
processed_at
)
`)
.eq('id', orderId)
.single()
.execute();
};
Advanced Relationship Techniques
Aggregated Relationships
// Include count of related records
const usersWithCounts = await client
.from('users')
.select(`
id,
name,
post_count:posts(count),
comment_count:comments(count)
`)
.execute();
// Include calculated fields from relationships
const categoriesWithStats = await client
.from('categories')
.select(`
id,
name,
products(count),
total_value:products(sum(price))
`)
.execute();
Conditional Relationship Fields
// Include different fields based on user role
const getPostsForUser = async (userId: number, userRole: string) => {
let selectClause = `
id,
title,
content,
author:users(name, avatar)
`;
if (userRole === 'admin') {
selectClause += `,
draft_notes,
internal_tags,
moderation_history:post_moderations(
action,
moderator:users(name),
created_at
)`;
}
return client
.from('posts')
.select(selectClause)
.eq('author_id', userId)
.execute();
};
Recursive Relationships
// Categories with subcategories (self-referential)
const getCategoryTree = async () => {
// Note: PostgREST has limitations with recursive queries
// You might need to use RPC functions for deep trees
return client
.from('categories')
.select(`
id,
name,
parent_id,
subcategories:categories!parent_id(
id,
name,
parent_id
)
`)
.is('parent_id', null) // Only root categories
.execute();
};
// For deeper recursion, use RPC
const getFullCategoryTree = async () => {
return client
.rpc('get_category_tree')
.execute();
};
Summary
PGRestify's relationship querying provides:
- Efficient Data Fetching: Retrieve complex nested data in single requests
- Flexible Relationships: Support for all relationship types (1:1, 1:many, many:many)
- Deep Nesting: Multi-level nested resource fetching
- Join Control: Inner and outer joins with explicit control
- Performance Optimization: Field selection and query optimization
- Dynamic Building: Runtime relationship construction
- Type Safety: Full TypeScript support for nested data structures
Master these relationship patterns to build efficient, maintainable data access layers that minimize database round trips while providing exactly the data your application needs.