alepha@docs:~/docs/guides/persistence$
cat 6-joins.md | pretty
2 min read
Last commit:

#Joins

Relations between tables are not declared on $entity. Instead, use the with option in repository query methods to perform joins at query time.

typescript
1import { t } from "alepha";2import { $entity, $repository, db } from "alepha/orm";

#Defining Related Entities

Use db.ref() to create foreign key columns that reference another entity:

typescript
 1const teams = $entity({ 2  name: "teams", 3  schema: t.object({ 4    id: db.primaryKey(), 5    name: t.text(), 6    country: t.text(), 7  }), 8}); 9 10const players = $entity({11  name: "players",12  schema: t.object({13    id: db.primaryKey(),14    teamId: db.ref(t.optional(t.integer()), () => teams.cols.id),15    name: t.text(),16    position: t.text(),17  }),18});

See Special Columns for db.ref() options like onDelete and onUpdate.

#Basic Join

Use the with option on getOne, findOne, findMany, or paginate to join related tables. The result includes the joined data as a nested object.

typescript
 1class PlayerService { 2  players = $repository(players); 3  4  async getPlayerWithTeam(playerId: number) { 5    return await this.players.getOne({ 6      where: { id: { eq: playerId } }, 7      with: { 8        team: { 9          join: teams,10          on: ["teamId", teams.cols.id],11        },12      },13    });14    // result.name     → "Messi"15    // result.team.name → "FC Barcelona"16  }17}

The on tuple maps [localColumn, foreignEntity.cols.foreignColumn].

#Join Types

Three join types are supported. The default is "left".

typescript
1with: {2  team: {3    type: "left",   // default — include rows even if no match (team will be undefined)4    join: teams,5    on: ["teamId", teams.cols.id],6  },7}
typescript
1with: {2  team: {3    type: "inner",  // exclude rows that have no matching team4    join: teams,5    on: ["teamId", teams.cols.id],6  },7}
typescript
1with: {2  team: {3    type: "right",  // include all teams, even if no player references them4    join: teams,5    on: ["teamId", teams.cols.id],6  },7}

With a left join, if the foreign key is NULL or there is no matching row, the joined field is undefined:

typescript
1const freeAgent = await this.players.getOne({2  where: { name: { eq: "Free Agent" } },3  with: {4    team: { join: teams, on: ["teamId", teams.cols.id] },5  },6});7// freeAgent.team → undefined

With an inner join, that row would be excluded entirely.

#Filtering on Joined Tables

Use the join alias in the where clause to filter by columns from the joined table:

typescript
 1const spanishPlayers = await this.players.findMany({ 2  with: { 3    team: { 4      join: teams, 5      on: ["teamId", teams.cols.id], 6    }, 7  }, 8  where: { 9    team: {10      country: { eq: "Spain" },11    },12  },13});

Combine base and joined table filters with and / or:

typescript
 1const results = await this.players.findMany({ 2  with: { 3    team: { 4      join: teams, 5      on: ["teamId", teams.cols.id], 6    }, 7  }, 8  where: { 9    and: [10      { position: { eq: "Forward" } },11      { team: { country: { eq: "Spain" } } },12    ],13  },14});
typescript
 1const results = await this.players.findMany({ 2  with: { 3    team: { 4      join: teams, 5      on: ["teamId", teams.cols.id], 6    }, 7  }, 8  where: { 9    or: [10      { goals: { gte: 200 } },11      { team: { country: { eq: "France" } } },12    ],13  },14});

All standard where clause operators work on joined table columns.

#Multiple Joins

Join several tables at the same level:

typescript
 1const result = await this.users.getOne({ 2  where: { id: { eq: userId } }, 3  with: { 4    profile: { 5      join: profiles, 6      on: ["id", profiles.cols.userId], 7    }, 8    city: { 9      join: cities,10      on: ["cityId", cities.cols.id],11    },12  },13});14// result.profile.bio → "Tech lead"15// result.city.name   → "Toronto"

Each join can have a different type:

typescript
 1with: { 2  profile: { 3    type: "inner", // must have a profile 4    join: profiles, 5    on: ["id", profiles.cols.userId], 6  }, 7  city: { 8    type: "left", // city is optional 9    join: cities,10    on: ["cityId", cities.cols.id],11  },12},

#Nested Joins

Nest with inside a join to follow relationships deeper:

typescript
 1// user → city → country 2const result = await this.users.getOne({ 3  where: { id: { eq: userId } }, 4  with: { 5    city: { 6      join: cities, 7      on: ["cityId", cities.cols.id], 8      with: { 9        country: {10          join: countries,11          on: ["countryId", countries.cols.id],12        },13      },14    },15  },16});17// result.city.name          → "Toronto"18// result.city.country.name  → "Canada"

Nesting works to arbitrary depth (3+ levels tested):

typescript
 1// post → author → city → country 2const post = await this.posts.getOne({ 3  where: { id: { eq: postId } }, 4  with: { 5    author: { 6      join: users, 7      on: ["authorId", users.cols.id], 8      with: { 9        city: {10          join: cities,11          on: ["cityId", cities.cols.id],12          with: {13            country: {14              join: countries,15              on: ["countryId", countries.cols.id],16            },17          },18        },19      },20    },21  },22});23// post.author.city.country.code → "CA"

Filtering also works on nested joins:

typescript
1where: {2  city: {3    country: {4      code: { eq: "CA" },5    },6  },7},

#Self-Referencing Joins

When an entity references itself (e.g. a managerId pointing to the same users table), use .alias() to disambiguate:

typescript
 1const users = $entity({ 2  name: "users", 3  schema: t.object({ 4    id: db.primaryKey(), 5    name: t.text(), 6    managerId: db.ref(t.optional(t.integer()), () => users.cols.id), 7  }), 8}); 9 10const manager = users.alias("manager");11 12const result = await this.users.getOne({13  where: { id: { eq: bobId } },14  with: {15    manager: {16      join: manager,17      on: ["managerId", manager.cols.id],18    },19  },20});21// result.manager.name → "Alice"

Aliases also work for nested self-references:

typescript
 1const manager = users.alias("manager"); 2const managerManager = users.alias("manager_manager"); 3  4const result = await this.users.getOne({ 5  where: { id: { eq: dianaId } }, 6  with: { 7    manager: { 8      join: manager, 9      on: ["managerId", manager.cols.id],10      with: {11        manager: {12          join: managerManager,13          on: ["managerId", managerManager.cols.id],14        },15      },16    },17  },18});19// result.manager.name          → "Bob"20// result.manager.manager.name  → "Alice"

More generally, whenever the same table appears more than once in a query (even through different join paths), use .alias() for each additional occurrence.

#SQL Join Conditions

For complex join conditions beyond simple column equality, pass a raw SQL expression instead of the tuple:

typescript
1import { sql } from "alepha/orm";2 3with: {4  profile: {5    join: profiles,6    on: sql`${users.cols.id} = ${profiles.cols.userId}`,7  },8},

Note: raw SQL join conditions are only supported on PostgreSQL. SQLite requires the tuple syntax.

#Joins with Pagination

paginate supports with just like findMany:

typescript
 1const page = await this.players.paginate( 2  { page: 0, size: 10 }, 3  { 4    with: { 5      team: { 6        join: teams, 7        on: ["teamId", teams.cols.id], 8      }, 9    },10    orderBy: { column: "name", direction: "asc" },11  },12);13// page.content[0].team.name → "FC Barcelona"

#Limitations

  • No one-to-many joins. The with option produces one-to-one joins (each row gets one joined object). For one-to-many relationships (e.g. a user's posts), run a separate query on the child table.
  • No orderBy on joined columns. Sorting is limited to columns on the base table.
  • No columns selection on joined tables. The full joined entity is always returned.
  • No aggregate queries with joins. Use repository.aggregate() separately or raw SQL via repository.query().
  • SQL join conditions require PostgreSQL. SQLite only supports the tuple syntax ["localCol", entity.cols.foreignCol].