join and leftJoin are methods for connecting multiple tables to query relational data. They support type-safe table joins and complex join conditions.
Basic Usage
INNER JOIN
const posts = await puri.table("posts")
.join("users", "posts.user_id", "users.id")
.select({
post_id: "posts.id",
title: "posts.title",
author_name: "users.name"
});
// SELECT posts.id as post_id, posts.title, users.name as author_name
// FROM posts
// INNER JOIN users ON posts.user_id = users.id
LEFT JOIN
const posts = await puri.table("posts")
.leftJoin("users", "posts.user_id", "users.id")
.select({
post_id: "posts.id",
title: "posts.title",
author_name: "users.name" // nullable
});
// SELECT posts.id as post_id, posts.title, users.name as author_name
// FROM posts
// LEFT JOIN users ON posts.user_id = users.id
INNER JOIN: Returns only matching records from both tables
LEFT JOIN: Returns all records from the left table (right side can be null)
Table Aliases
Using Aliases
const posts = await puri.table("posts")
.join({ u: "users" }, "posts.user_id", "u.id")
.select({
post_id: "posts.id",
author_name: "u.name" // Using alias
});
// FROM posts
// INNER JOIN users AS u ON posts.user_id = u.id
Joining Same Table Multiple Times
const posts = await puri.table("posts")
.join({ author: "users" }, "posts.author_id", "author.id")
.leftJoin({ editor: "users" }, "posts.editor_id", "editor.id")
.select({
post_id: "posts.id",
author_name: "author.name",
editor_name: "editor.name" // nullable
});
// FROM posts
// INNER JOIN users AS author ON posts.author_id = author.id
// LEFT JOIN users AS editor ON posts.editor_id = editor.id
Complex Join Conditions
Using Callback Function
You can combine multiple conditions.
const result = await puri.table("orders")
.join("users", (j) => {
j.on("orders.user_id", "users.id")
.on("orders.country", "users.country");
})
.select({
order_id: "orders.id",
user_name: "users.name"
});
// FROM orders
// INNER JOIN users ON orders.user_id = users.id
// AND orders.country = users.country
OR Conditions
const result = await puri.table("products")
.leftJoin("categories", (j) => {
j.on("products.category_id", "categories.id")
.orOn("products.parent_category_id", "categories.id");
})
.select({
product_name: "products.name",
category_name: "categories.name"
});
// LEFT JOIN categories ON products.category_id = categories.id
// OR products.parent_category_id = categories.id
Comparison with Values
Use onVal() instead of on() when comparing a column with a literal value. on() treats its arguments as column references, so onVal() is required to bind values as parameters.
const result = await puri.table("orders")
.join("users", (j) => {
j.on("orders.user_id", "users.id")
.onVal("users.status", "=", "active") // Bind value as parameter
.onVal("orders.created_at", ">", new Date("2024-01-01"));
})
.selectAll();
Value Binding (onVal)
onVal safely compares a column with a literal value using parameter binding in join conditions. While on is used for column-to-column comparisons, onVal is used for column-to-value comparisons.
const result = await puri.table("orders")
.join("users", (j) => {
j.on("orders.user_id", "users.id")
.onVal("users.status", "active"); // Parameter binding
})
.select({
order_id: "orders.id",
user_name: "users.name"
});
// INNER JOIN users ON orders.user_id = users.id
// AND users.status = ? -- 'active' bound as parameter
You can also specify an operator.
const result = await puri.table("orders")
.join("shipments", (j) => {
j.on("orders.id", "shipments.order_id")
.onVal("shipments.status", "!=", "cancelled");
})
.selectAll();
// INNER JOIN shipments ON orders.id = shipments.order_id
// AND shipments.status != ? -- 'cancelled'
andOnVal / orOnVal
andOnVal is an explicit AND version of onVal, and orOnVal compares values with OR conditions.
const result = await puri.table("products")
.leftJoin("promotions", (j) => {
j.on("products.id", "promotions.product_id")
.onVal("promotions.active", true)
.orOnVal("promotions.featured", true);
})
.select({
product_name: "products.name",
promo_name: "promotions.name"
});
// LEFT JOIN promotions ON products.id = promotions.product_id
// AND promotions.active = ? -- true
// OR promotions.featured = ? -- true
on vs onVal: on("col1", "col2") compares two columns. onVal("col", value) compares a column with a value, binding the value as a SQL parameter.
Subquery Joins
You can join subqueries as tables.
// Create subquery
const recentOrders = puri.table("orders")
.where("orders.created_at", ">", new Date("2024-01-01"))
.select({
user_id: "orders.user_id",
order_count: Puri.count()
})
.groupBy("orders.user_id");
// Join subquery
const result = await puri.table("users")
.join(
{ ro: recentOrders }, // Alias required
"users.id",
"ro.user_id"
)
.select({
user_id: "users.id",
user_name: "users.name",
order_count: "ro.order_count"
});
// FROM users
// INNER JOIN (
// SELECT user_id, COUNT(*) as order_count
// FROM orders
// WHERE created_at > '2024-01-01'
// GROUP BY user_id
// ) AS ro ON users.id = ro.user_id
Multiple Table Joins
You can sequentially join multiple tables.
const posts = await puri.table("posts")
.join("users", "posts.user_id", "users.id")
.leftJoin("categories", "posts.category_id", "categories.id")
.leftJoin("tags", "posts.id", "tags.post_id")
.select({
// posts
post_id: "posts.id",
title: "posts.title",
// users
author_name: "users.name",
author_email: "users.email",
// categories (nullable)
category_name: "categories.name",
// tags (nullable)
tag_name: "tags.name"
});
// FROM posts
// INNER JOIN users ON posts.user_id = users.id
// LEFT JOIN categories ON posts.category_id = categories.id
// LEFT JOIN tags ON posts.id = tags.post_id
Real-World Examples
async function getPostsWithAuthors() {
return puri.table("posts")
.join("users", "posts.user_id", "users.id")
.where("posts.published", true)
.select({
// Post information
id: "posts.id",
title: "posts.title",
content: "posts.content",
created_at: "posts.created_at",
// Author information
author: {
id: "users.id",
name: "users.name",
email: "users.email"
}
})
.orderBy("posts.created_at", "desc")
.limit(20);
}
async function getOrderDetails(orderId: number) {
return puri.table("orders")
.join("users", "orders.user_id", "users.id")
.join("order_items", "orders.id", "order_items.order_id")
.join("products", "order_items.product_id", "products.id")
.where("orders.id", orderId)
.select({
// Order information
order_id: "orders.id",
order_status: "orders.status",
order_date: "orders.created_at",
// User information
customer: {
name: "users.name",
email: "users.email",
phone: "users.phone"
},
// Product information
items: {
product_id: "products.id",
product_name: "products.name",
quantity: "order_items.quantity",
price: "order_items.price"
}
});
}
async function getUserStatistics() {
return puri.table("users")
.leftJoin("posts", "users.id", "posts.user_id")
.leftJoin("comments", "users.id", "comments.user_id")
.select({
user_id: "users.id",
user_name: "users.name",
// Post count
post_count: Puri.rawNumber(
"COUNT(DISTINCT posts.id)"
),
// Comment count
comment_count: Puri.rawNumber(
"COUNT(DISTINCT comments.id)"
),
// Last activity
last_activity: Puri.rawDate(
"GREATEST(MAX(posts.created_at), MAX(comments.created_at))"
)
})
.groupBy("users.id", "users.name")
.having("post_count", ">", 0)
.orderBy("post_count", "desc");
}
async function getUsersWithManagers() {
return puri.table({ emp: "users" })
.leftJoin(
{ mgr: "users" },
"emp.manager_id",
"mgr.id"
)
.select({
// Employee information
employee_id: "emp.id",
employee_name: "emp.name",
employee_email: "emp.email",
// Manager information (nullable)
manager: {
id: "mgr.id",
name: "mgr.name",
email: "mgr.email"
}
})
.orderBy("emp.name", "asc");
}
async function getTopContributors(limit: number = 10) {
// Calculate post count per user (subquery)
const postCounts = puri.table("posts")
.where("posts.published", true)
.select({
user_id: "posts.user_id",
post_count: Puri.count()
})
.groupBy("posts.user_id");
// Join with user information
return puri.table("users")
.join(
{ pc: postCounts },
"users.id",
"pc.user_id"
)
.select({
id: "users.id",
name: "users.name",
email: "users.email",
post_count: "pc.post_count"
})
.orderBy("pc.post_count", "desc")
.limit(limit);
}
Join Condition Grouping
You can group complex join conditions.
const result = await puri.table("products")
.leftJoin("discounts", (j) => {
j.on("products.id", "discounts.product_id")
.on((nested) => {
nested.onVal("discounts.start_date", "<=", new Date())
.onVal("discounts.end_date", ">=", new Date());
});
})
.select({
product_id: "products.id",
product_name: "products.name",
discount_rate: "discounts.rate"
});
// LEFT JOIN discounts ON products.id = discounts.product_id
// AND (
// discounts.start_date <= NOW()
// AND discounts.end_date >= NOW()
// )
NULL Handling (LEFT JOIN)
LEFT JOIN results are automatically inferred as nullable types.
const posts = await puri.table("posts")
.leftJoin("users", "posts.user_id", "users.id")
.select({
post_id: "posts.id",
title: "posts.title",
author_name: "users.name" // string | null
});
// Type check
posts.forEach(post => {
if (post.author_name !== null) {
console.log(post.author_name.toUpperCase());
}
});
1. Select Only Needed Columns
// ❌ Bad: All columns
.join("users", ...)
.selectAll()
// ✅ Good: Only needed columns
.join("users", ...)
.select({
post_id: "posts.id",
author_name: "users.name" // Only what's needed
})
2. Join Order
// ✅ Good: Join smaller tables first
.table("posts") // Large table
.join("users", ...) // Small table
.join("categories", ...)
// Use indexes in join conditions
.join("users", "posts.user_id", "users.id") // users.id is PK (indexed)
3. LEFT JOIN vs INNER JOIN
// LEFT JOIN is slower than INNER JOIN
// Use INNER JOIN if relationship must exist
// ✅ If user_id is NOT NULL, use INNER JOIN
.join("users", "posts.user_id", "users.id")
// ⚠️ If user_id can be NULL, use LEFT JOIN
.leftJoin("users", "posts.user_id", "users.id")
Type Safety
Columns from joined tables support automatic type completion and validation.
const posts = await puri.table("posts")
.join("users", "posts.user_id", "users.id")
.select({
post_id: "posts.id", // ✅ OK
user_name: "users.name", // ✅ OK
// ❌ Type error: Non-existent column
unknown: "posts.unknown_field",
// ❌ Type error: Non-joined table
category: "categories.name"
});
Cautions
1. Alias Required (Subqueries)
// ❌ Error: Subquery needs alias
.join(subquery, ...)
// ✅ Correct
.join({ sq: subquery }, ...)
2. Check Join Condition Tables
// ❌ Wrong: Referencing non-joined table
.table("posts")
.join("users", "comments.user_id", "users.id") // No comments table
// ✅ Correct
.table("posts")
.join("users", "posts.user_id", "users.id")
3. Duplicate Column Names
// ⚠️ Warning: Column name collision
.table("posts")
.join("users", ...)
.select({
id: "posts.id", // ❌ Will be overwritten
id: "users.id" // ❌
})
// ✅ Correct: Use aliases
.select({
post_id: "posts.id",
user_id: "users.id"
})
4. N+1 Problem
// ❌ Bad: N+1 queries
for (const post of posts) {
const user = await puri.table("users")
.where("users.id", post.user_id)
.first();
}
// ✅ Good: JOIN once
const posts = await puri.table("posts")
.join("users", "posts.user_id", "users.id")
.select({
post_id: "posts.id",
user_name: "users.name"
});
JOIN vs Loader
Puri provides two methods for loading relational data.
JOIN (1:1 Relationship)
// ✅ Suitable: 1:1 or N:1
.table("posts")
.join("users", "posts.user_id", "users.id")
Loader (1:N Relationship)
// ✅ Suitable: 1:N or N:M
// Handle with Subset Loader (separate query)
JOIN multiplies rows, so use Loader for 1:N relationships.
Next Steps