Skip to main content
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);
}

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());
  }
});

Performance Optimization

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