#Joins
Relations between tables are not declared on $entity. Instead, use the with option in repository query methods to perform joins at query time.
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:
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.
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".
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}
1with: {2 team: {3 type: "inner", // exclude rows that have no matching team4 join: teams,5 on: ["teamId", teams.cols.id],6 },7}
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:
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:
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:
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});
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:
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:
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:
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):
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:
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:
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:
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:
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:
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
withoption 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
orderByon joined columns. Sorting is limited to columns on the base table. - No
columnsselection on joined tables. The full joined entity is always returned. - No aggregate queries with joins. Use
repository.aggregate()separately or raw SQL viarepository.query(). - SQL join conditions require PostgreSQL. SQLite only supports the tuple syntax
["localCol", entity.cols.foreignCol].