Skip to content
NewComposite primary keys5 min read

Relations

Relations are declared with four decorators on the entity class. mappedBy takes either a property name or a callback, the callback surviving a rename.

import { v7 as uuidv7 } from 'uuid';
import {
Entity,
Id,
Field,
OneToOne,
OneToMany,
ManyToOne,
ManyToMany,
} from 'uql-orm';
@Entity()
export class User {
@Id({
type: 'uuid',
onInsert: uuidv7,
})
id?: string;
@Field({ type: String })
name?: string;
/**
* One-to-One: A user has one profile.
* mappedBy can be a callback for better refactoring support.
*/
@OneToOne({
entity: () => Profile,
mappedBy: (profile) => profile.user,
cascade: true,
})
profile?: Profile;
/**
* One-to-Many: A user can have many posts.
*/
@OneToMany({
entity: () => Post,
mappedBy: (post) => post.author,
})
posts?: Post[];
}
@Entity()
export class Profile {
@Id({
type: 'uuid',
onInsert: uuidv7,
})
id?: string;
@Field({ type: String })
picture?: string;
/**
* Foreign key column. The 'references' option points at the target entity;
* the column type is inherited from the target's primary key.
*/
@Field({ references: () => User })
userId?: string;
@OneToOne({ entity: () => User })
user?: User;
}
@Entity()
export class Post {
@Id({ type: Number })
id?: number;
@Field({ type: String })
title?: string;
@Field({ references: () => User })
authorId?: string;
@ManyToOne({ entity: () => User })
author?: User;
/**
* Many-to-Many: A post can have many tags.
* 'through' specifies the pivot entity.
*/
@ManyToMany({
entity: () => Tag,
through: () => PostTag,
cascade: true,
})
tags?: Tag[];
}
@Entity()
export class Tag {
@Id({
type: 'uuid',
onInsert: uuidv7,
})
id?: string;
@Field({ type: String })
name?: string;
}
@Entity()
export class PostTag {
@Id({
type: 'uuid',
onInsert: uuidv7,
})
id?: string;
@Field({ references: () => Post })
postId?: number;
@Field({ references: () => Tag })
tagId?: string;
}

A to-one relation carries its own foreign key, so nothing more is needed. A to-many has no such column and needs one of three, which the compiler requires and the entity re-checks when it is first resolved:

Option Use
mappedBy The inverse side: names the field on the other entity that holds the foreign key or relation.
through A junction entity with a foreign key to each side. Works for @ManyToMany and @OneToMany alike.
references The join columns, by name, when neither convention fits.

With through, both join columns are read from the junction and named after the two entities (postId, tagId above). A junction missing one of them is also reported when the entity is resolved, rather than at the first query.

 

Scalar columns go in $select, related entities in $populate, each with its own selection and filter:

You write
const posts = await pool.findMany(Post, {
$select: { id: true, title: true },
$populate: {
author: {
$select: { id: true, name: true },
},
tags: {
$select: { name: true },
$where: { name: { $istartsWith: 'typescript' } },
},
},
$where: {
author: { name: 'Roger' },
},
});
-- Main query with LEFT JOIN for ManyToOne
SELECT "Post"."id", "Post"."title",
"author"."id" "author.id", "author"."name" "author.name"
FROM "Post"
LEFT JOIN "User" "author" ON "author"."id" = "Post"."authorId"
WHERE EXISTS (
SELECT 1 FROM "User"
WHERE "User"."id" = "Post"."authorId" AND "User"."name" = $1
)
-- ManyToMany tags loaded via a second query: it reads the junction and joins the
-- target, so the tag's own $where lands on the join, and every parent's ids arrive
-- as one array parameter rather than an expanded IN list.
SELECT "PostTag"."postId", "tag"."id" "tag.id", "tag"."name" "tag.name"
FROM "PostTag"
INNER JOIN "Tag" "tag" ON "tag"."id" = "PostTag"."tagId" AND "tag"."name" ILIKE $1
WHERE "PostTag"."postId" = ANY($2)

Check the querying relations section for more advanced examples on deep filtering and selection. Against a database that already has tables, these decorators are written for you: generate:from-db infers them from the foreign keys it finds.